Refactor to Purity
Pure Functions
sind Program-Methoden, die ohne Seiteneffekte auszulösen ausgeführt werden können. In der funktionalen Programmierung sind sie eher die Regel als die Ausnahme. In den meisten objektorientierten Sprachen hingegen begegnet man ihnen aber eher weniger, oder zumindest werden sie häufig nicht als das Mittel der Wahl in Betracht gezogen. Im dotnet-Umfeld wird viel über Dependency Injection und mehr oder weniger umfangreiche Abstraktionen mittels Interfaces abgehandelt.
Wie man von einer Codebasis mit vielen solchen Indirektionen zu einer einfacheren Variante kommt, die ein Vieles an überflüssiger Komplexität entfernt, soll der folgende Artikel zeigen.
Ausgangslage
Als Ausgangspunkt für unser Refactoring nehmen wir ein fiktives Beispiel eines Online-Shops an. Der Quellcode ist auf GitHub verfügbar, für jeden Refactoring-Schritt gibt es eine eigene Branch im Repository.
Quellcode auf GitHub, Branch steps/01-initial-state
Die Anwendung besteht aus einem Applikations-Projekt und einem für dazugehörige Tests. Der Aufbau folgt nicht streng einem Architektur-Style sondern soll lediglich zeigen, welche Komponenten man bei einem solchen System erwarten könnte. Zudem beschränken wir uns hier auf die Backend-Seite unseres Online-Shops. Die Ordner-Struktur sieht wie folgt aus:
├───Refactor.Application
│ ├───Controllers
│ ├───CQRS
│ │ ├───Handlers
│ │ └───Requests
│ ├───Data
│ ├───Models
│ ├───Repositories
│ │ ├───Implementations
│ │ └───Interfaces
│ └───Services
└───Refactor.Application.Test
├───Controllers
├───CQRS
│ └───Handlers
├───Repositories
└───Services
Die Anwendung ist in C# geschrieben und verwendet ASP.NET Controller
. Business-Logik ist in Service
-Klassen implementiert, Domain-Modelle liegen im Ordner Models
. Der Zugriff auf eine Datenbank erfolgt mittels Repository
-Pattern. Die POCO-Klassen für die Datenbank sind im Ordner Data
untergebracht. Für die Kommunikation zwischen Controllern und Services kommt das CQRS (Command Query Responsibility Segregation)-Pattern zum Einsatz.
Zusammengehalten werden unsere einzelnen Komponenten mittels Dependency Injection.
Abstraktion
In der Softwareentwicklung arbeitet man gerne mit Abstraktion. Ihrem eigentlichen Ziel, Komplexität und Wartungsaufwand zu reduzieren, wird sie aber oft nicht gerecht. Zudem werden häufig Abstraktionsschichten eingezogen, ohne das diese einen konkreten Nutzen bringen, aber “man macht es halt so”. Dadurch leidet nicht nur die Lesbarkeit des Codes, auch das Laufzeitverhalten kann erst nach einer Analyse der Abhängigkeiten verstanden werden. Die Abstraktionen in unserem Beispiel mögen für eine kleine Demo vielleicht künstlich erzwungen wirken, sind in realen Projekten aber durchaus immer wieder anzutreffen.
Basisklassen und Marker-Interfaces
Unsere Model-Klassen erben allesamt von einer abstrakten Basisklasse bzw. Record ModelBase
welche keinerlei Implementierung mitbringt. Die Datenbank-POCOs implementieren ein Interface IData
, welches zumindest eine Property Id
definiert.
// ./Models
public abstract record ModelBase;
public record Customer(
Guid Id,
string FirstName,
string LastName,
string Email) : ModelBase;
// ./Data
public interface IData
{
Guid Id { get; }
}
public record Customer(
Guid Id,
string FirstName,
string LastName,
string Email,
bool Active) : IData;
Repository-Interfaces
Im Ordner Repositories
finden sich sowohl ein generisches Interface IRepository<T>
als auch spezifische Interfaces je Datenbank-Tabelle bzw. POCO-Klasse, z.B. ICustomerRepository
. Dazu eine abstrakte Basisklasse AbstractRepository<T>
, welche nur eins-zu-eins die Methoden des generischen Interfaces implementiert.
public interface IRepository<T> where T : IData
{
T Get(Guid id);
IEnumerable<T> GetAll();
void Add(T entity);
...
}
public abstract class AbstractRepository<T> : IRepository<T> where T : IData
{
protected readonly IDatabase _database;
protected AbstractRepository(IDatabase database) => _database = database;
public abstract T Get(Guid id);
public abstract IEnumerable<T> GetAll();
public abstract void Add(T entity);
...
}
Die Abstraktion des konkreten Datenbank-Zugriffs über ein Interface IDatabase
kann sinnvoll sei, da man beim Testen externe Systeme wie eine Datenbank durch ein Mock-Objekt ersetzen kann. Im Weiteren werden wir aber eine andere Lösung für dieses Problem finden.
Die konkreten Implementierungen der Repositories sehen dann zumeist nur so aus, dass die Aufrufe an die Basisklasse oder ein IDatabase
-Objekt weitergeleitet werden.
public class CustomerRepository : AbstractRepository<Customer>, ICustomerRepository
{
public CustomerRepository(IDatabase database) : base(database) { }
public override void Add(Customer entity) => _database.Add(entity);
public override void Update(Customer entity) => _database.Update(entity);
...
}
Services und CQRS
Unsere Services sind allesamt als Interfaces und genau einer Implementierung ausgeführt; der Bedarf, mehrere Implementierungen zu haben und evtl. sogar zur Laufzeit auszutauschen besteht nicht.
Das Beispiel eines ITaxService
zeigt den inflationären Einsatz von Interfaces. Das Interface definiert nur eine einzelne Methode, diese hat keinerlei Abhängigkeiten ausser ihrer direkten Methoden-Parameter.
public interface ITaxService
{
(decimal taxAmount, decimal grossPrice) CalculateTax(
decimal netPrice, decimal taxRate);
}
public class TaxService : ITaxService
{
public (decimal taxAmount, decimal grossPrice) CalculateTax(
decimal netPrice, decimal taxRate)
{
var taxAmount = netPrice * taxRate / 100m;
var grossPrice = netPrice + taxAmount;
return (taxAmount, grossPrice);
}
}
Tests
Wie sieht nun ein (Unit-)Test für solchen Code aus? Das Testen der Methode GetOrderItems()
des OrderItemService
zeigt, wie viel Setup-Code bereits jetzt notwendig ist, um die Abhängigkeiten zu mocken und mit Daten zu füttern. Im Falle des ITaxService
-Interfaces wird sogar die Businesslogik im Mock-Objekt implementiert.
[Test]
public void Should_Return_OrderItems()
{
// Arrange
var orderId = Guid.NewGuid();
var orderItem1 = new OrderItem(Guid.NewGuid(),
orderId, Guid.NewGuid(), 2, 19.75m);
var orderItem2 = new OrderItem(Guid.NewGuid(),
orderId, Guid.NewGuid(), 3, 9.66m);
var orderItemData = new List<OrderItem> { orderItem1, orderItem2 };
var orderItemRepository = Substitute.For<IOrderItemRepository>();
orderItemRepository.GetByOrderId(orderId).Returns(orderItemData);
var taxService = Substitute.For<ITaxService>();
taxService.CalculateTax(default, default)
.ReturnsForAnyArgs(info =>
{
var netPrice = info.ArgAt<decimal>(0);
var taxRate = info.ArgAt<decimal>(1);
var taxAmount = netPrice * taxRate / 100m;
var grossPrice = netPrice + taxAmount;
return (taxAmount, grossPrice);
});
var sut = new OrderItemService(orderItemRepository, taxService);
// Act
var orderItems = sut.GetOrderItems(orderId);
// Assert
orderItems.Should().NotBeNullOrEmpty();
orderItems.Should().HaveCount(2);
var firstOrderItem = orderItems.First();
firstOrderItem.Id.Should().Be(orderItem1.Id);
firstOrderItem.TaxRate.Should().Be(19);
firstOrderItem.GrossPrice.Should().Be(19.75m * 1.19m);
}
Wie man bereits mit geringem Aufwand zu erheblich weniger Setup-Code für Test kommen kann, zeigt Schritt 1 unseres Refactorings.
Code-Analyse
Die Exploration unsere Anwendung mittels Sonargraph zeigt, mit wie vielen Abhängigkeiten zwischen den einzelnen Klassen wir bereits jetzt zu tun haben.
Die Codebasis besteht zu diesem Zeitpunkt aus 917 Zeilen Quellcode in 53 Dateien und weist eine Average Component Dependency ACD
von 5,3 auf.
Schritt 1: Test-Dummies
Im ersten Schritt nehmen wir uns der Test-Klassen an. Eine gute Test-Suite ist die Grundlage eines sicheren Refactorings, deshalb wollen wir hier beginnen.
Getreu dem Motto new is glue
verlagern wir das Instanziieren von Testdaten aus den Testmethoden heraus in Dummies
aus. Zu dem Thema Dummy-Factories existiert ein dedizierter Blog Post Einfaches Test-Setup mit Dummy-Factories, daher soll hier nur kurz auf Änderungen an unserem Beispiel-Code eingegangen werden.
Quellcode auf GitHub, Branch steps/02-introduce-dummies
Wir fügen eine Klasse DataDummies
hinzu, welche uns das Instanziieren von Daten-Objekten abnimmt. Zudem definieren wir ein paar statische Instanzen von Customer
-Objekten, die wir in unseren Tests verwenden können.
internal static class DataDummies
{
public static Customer JohnDoe => Customer(
new Guid("bfbffb19-cdd4-42ac-b536-606a16d03eae"), "John",
"Doe", "john.doe@example.com");
public static Customer JaneDoe => Customer(
new Guid("95a6db4a-4635-4fb3-b7f6-c206ff7272f1"), "Jane",
"Doe", "Jane.doe@example.com", false);
public static Customer Customer(
Guid? id = null, string firstName = "Peter", string lastName = "Parker",
string email = "peter.parker@example.com", bool active = true)
{
return new Customer(id ?? Guid.NewGuid(),
firstName, lastName, email, active);
}
...
}
Für unsere Domain-Objekte verfahren wir ebenso. Hier können wir uns zunutze machen, dass POCO-Klassen und Domain-Modelle zumeist sehr ähnlich aufgebaut sind und wir die Daten-Objekte aus DataDummies
verwenden können.
internal static class ModelDummies
{
public static Customer JohnDoe => FromData(DataDummies.JohnDoe);
public static Customer JaneDoe => FromData(DataDummies.JaneDoe);
public static Customer FromData(Data.Customer data)
{
return Customer(id: data.Id, firstName: data.FirstName,
lastName: data.LastName, email: data.Email);
}
...
}
Unser Test-Setup wird damit schon etwas einfacher und vor allem resilienter gegenüber Änderungen an den Daten-Objekten, da wir diese nur noch an einer Stelle anpassen müssen.
[Test]
public void Should_Return_OrderItems()
{
// Arrange
var orderId = Guid.NewGuid();
var orderItem1 = OrderItem(price: 19.75m);
var orderItem2 = OrderItem(price: 9.66m);
var orderItemData = Collection(orderItem1, orderItem2);
var orderItemRepository = Substitute.For<IOrderItemRepository>();
orderItemRepository.GetByOrderId(orderId).Returns(orderItemData);
...
}
Nach diesen Vorbereitungen können wir uns dem Refactoring des Produktivcodes zuwenden.
Schritt 2: Interfaces entfernen
Im zweiten Schritt wollen überflüssige Abstraktionen durch Interfaces und Basisklassen entfernen. Häufig wird damit argumentiert, dass Test-Code sich diese zunutze machen und Abhängigkeiten, die als Interfaces ausgeführt sind, einfach durch Mocks, Stubs oder Fakes ersetzen kann. Bei externen Abhängigkeiten, etwa zu Datenbanken oder Email-Servern, ist dies sicher richtig; für selbst geschaffene Abstraktionen führt es aber zumeist nur zu unnötiger Komplexität und hohem Aufwand für das Test-Setup. Test-Mocks sind aufwendig zu pflegen und müssen Internas der echten Implementierung kennen und diese nachbauen.
Unser erster Ansatzpunkt ist der TaxService
. Die Methode CalculateTax()
ist bereits eine pure Funktion. Daher können wir das Interface ITaxService
löschen, die Klasse und die Methode static
machen und diese einfach direkt aufrufen. Es ist keine Dependency-Injection notwendig und der Test-Mock fällt ebenfalls weg.
public static class TaxService
{
public static (decimal taxAmount, decimal grossPrice) CalculateTax(
decimal netPrice, decimal taxRate)
{
...
}
}
Der entsprechende Git-Commit zeigt uns 43 gelöschte Zeilen.
Wenden wir uns nun den Service-Klassen OrderService
und OrderItemService
zu. Von den per Constructor Injection bereitgestellten Abhängigkeiten (e.g. ICustomerRepository
) benötigen wir nur einzelne Methoden oder gar nur den Rückgabewert einer Methode. Statt nun Repository-Klassen zu injizieren, übergeben wir Methoden-Pointer (Delegates) an die Service-Klassen. Dadurch entfallen die privaten Properties, die Klassen werden zustandslos und static
und die Interfaces können entfernt werden.
Die Klasse OrderService
hatte zuvor drei Abhängigkeiten.
public class OrderService : IOrderService
{
private readonly ICustomerRepository _customerRepository;
private readonly IOrderItemRepository _orderItemRepository;
private readonly IOrderRepository _orderRepository;
public OrderService(IOrderRepository orderRepository,
ICustomerRepository customerRepository,
IOrderItemRepository orderItemRepository)
{
_orderRepository = orderRepository;
_customerRepository = customerRepository;
_orderItemRepository = orderItemRepository;
}
public Order GetOrder(Guid id)
{
var orderData = _orderRepository.Get(id);
return GetOrder(orderData);
}
private Order GetOrder(Data.Order orderData)
{
var customerData = _customerRepository.Get(orderData.CustomerId);
var orderItemData = _orderItemRepository.GetByOrderId(orderData.Id);
...
return orderModel;
}
}
Nach dem Refactoring sieht die Klasse so aus:
public static class OrderService
{
public static Order GetOrder(Guid id,
Func<Guid, Data.Order> getOrder,
Func<Guid, Customer> getCustomer,
Func<Guid, IReadOnlyCollection<OrderItem>> getOrderItems)
{
var orderData = getOrder(id);
var customerData = getCustomer(orderData.CustomerId);
var orderItemData = getOrderItems(id);
return GetOrder(orderData, customerData, orderItemData);
}
...
}
Aufgerufen wird die Methode GetOrder()
nun einfach, indem wir die entsprechenden Methoden der Repositories als Parameter übergeben.
var orders = OrderService.GetOrder(
id: id,
getOrder: _orderRepository.Get,
getCustomer: _customerRepository.Get,
getOrderItems: _orderItemRepository.GetByOrderId);
Unterscheiden sich die Signaturen der Methoden, können wir diese einfach per Lambda-Expression anpassen.
var orders = OrderService.GetOrder(
id: id,
getCustomer: id => _customerRepository.Get(id: id, activeOnly: true),
...
Die entsprechenden Unit-Tests vereinfachen sich ebenfalls; wir müssen keine Mock-Objekte mehr zusammenbauen, sondern müssen lediglich Methoden definieren. Als lokale Lambda-Expressions sind dies Einzeiler.
var getOrder = (Guid _) => DataDummies.Order(orderId, peterPan.Id);
var getCustomer = (Guid _) => peterPan;
var getByOrderId = (Guid _) => DataDummies.Collection(orderItem1, orderItem2);
// Act
var order = OrderService.GetOrder(orderId, getOrder, getCustomer, getByOrderId);
Alternativ kann man bei puren Funktionen anstatt einer Methode den Rückgabewert eben dieser als Parameter übergeben (Referenzielle Transparenz). Für Methoden, die Seiteneffekte haben (z.B. Datenbank-Update) oder große Datenmengen filtern, ist dies aber nicht immer sinnvoll.
var order = _orderRepository.Get(id);
var customer = _customerRepository.Get(order.CustomerId);
var orderItems = _orderItemRepository.GetByOrderId(id);
var orders = OrderService.GetOrder(order, customer, orderItems);
Dadurch, dass wir Abhängigkeiten nun nicht mehr per Dependency Injection in eine Klasse bringen, sondern per Methoden-Parameter übergeben, verschieben wir die Verantwortung für das Erzeugen und Verwalten der Abhängigkeiten an den aufrufenden Code.
Schritt 3: CQRS entfernen
Als nächstes entfernen wir das mittels MediatR umgesetzte CQRS-Pattern aus unserer Codebasis. Die Library ist großartig und CQRS ist ein mächtiges Werkzeug, wenn man tatsächlich den Bedarf hat, Commands und Queries zu trennen. In unserem Beispiel soll es aber zeigen, dass dies häufig nicht benötigt wird und vielleicht nur Premature Optimization ist, die nie zum Tragen kommt.
Statt den Quellcode, der Controller und Domain-Logik verbindet, über mehrere IRequest
und IRequestHandler<>
zu verteilen, fassen wir ihn in wenigen Integrations-Klassen zusammen.
Statt eines AddOrderHandler
inkl. entsprechendem AddOrderRequest
haben wir nun eine einzelne Methode, welche die benötigten Abhängigkeiten als Parameter übergeben bekommt und den Aufruf der Service-Klassen orchestriert.
public static class OrdersIntegration
{
public static void AddOrder(Order order,
ICustomerRepository customerRepository,
IOrderItemRepository orderItemRepository,
IOrderRepository orderRepository)
{
if (!order.Items.Any())
throw new InvalidOperationException("Order must have at least one item.");
var customerData = customerRepository.Get(order.Customer.Id);
if (customerData.Active is false)
throw new InvalidOperationException("Customer is not active.");
foreach (var orderItem in order.Items)
{
var orderItemData = OrderItemService.AddOrderItem(orderItem, order);
orderItemRepository.Add(orderItemData);
}
OrderService.AddOrder(order, orderRepository.Add);
}
...
}
In einem weiteren Schritt können wir, wie zuvor für die Services, von dem injizieren von Repositories auf Methoden-Delegates umstellen. Dadurch kommen wir in die Lage, sämtliche IRepository
-Interfaces entfernen zu können, da wir diese in unseren Tests nicht mehr substituieren müssen. Ein Git-Commit zeigt das exemplarisch für das IOrderRepository
.
Schritt 4: Statische Repositories
Nachdem wir ein paar weitere abstrakte Basisklassen und Interfaces entfernt haben, schauen wir uns noch einmal die Repository-Klassen an. Sie weisen allesamt lediglich eine Abhängigkeit zu IDatabase
auf. Die Umstellung von Constructor Injection auf Method Injection ist schnell gemacht.
- public class OrderRepository
+ public static class OrderRepository
{
- private readonly IDatabase _database;
- public OrderRepository(IDatabase database) => _database = database;
- public IEnumerable<OrderData> GetOrdersByDate(
- DateTime startDate, DateTime endDate)
- => _database.GetAll<OrderData>()
- .Where(x => x.OrderDate >= startDate && x.OrderDate <= endDate);
+ public static IEnumerable<OrderData> GetOrdersByDate(
+ DateTime startDate, DateTime endDate, IDatabase db)
+ => db.GetAll<OrderData>()
+ .Where(x => x.OrderDate >= startDate && x.OrderDate <= endDate);
...
}
Wenn wir auch hier noch einen Schritt weiter gehen und statt einer Instanz von IDatabase
lediglich Delegates als Methoden-Parameter erwarten, können wir die Abhängigkeit von IDatabase
ganz aus unseren Repositories entfernen.
public static class OrderRepository
{
- public static IEnumerable<OrderData> GetOrdersByDate(
- DateTime startDate, DateTime endDate, IDatabase db)
- => db.GetAll<OrderData>()
- .Where(x => x.OrderDate >= startDate && x.OrderDate <= endDate);
+ public static IEnumerable<OrderData> GetOrdersByDate(
+ DateTime startDate, DateTime endDate,
+ Func<IEnumerable<OrderData>> getAll)
+ => getAll().Where(x => x.OrderDate >= startDate &&
+ x.OrderDate <= endDate);
...
}
Alternativ kann es sich hier anbieten, anstatt der Methoden bereits die Rückgabewerte dieser an unsere Service-Methode zu übergeben. Damit sind sämtliche Seiteneffekte eliminiert und wir haben eine pure function
.
public static IReadOnlyCollection<Order> GetOrdersByDate(
DateTime startDate, DateTime endDate,
IEnumerable<OrderData> allOrderData,
IDictionary<Guid, CustomerData> customerData,
ILookup<Guid, OrderItemData> orderItemData)
{
return allOrderData
.Where(x => x.OrderDate >= startDate && x.OrderDate <= endDate)
.Select(order => GetOrder(order,
customerData[order.CustomerId], orderItemData[order.Id]))
.ToList();
}
Die aufrufende Methode ist nun für das zusammensammeln der Daten verantwortlich.
var allOrderData = db.GetAll<OrderData>();
var customerData = db.GetAll<CustomerData>()
.ToDictionary(x => x.Id, x => x);
var orderData = db.GetAll<OrderItemData>()
.ToLookup(x => x.OrderId);
var orders = OrderService.GetOrdersByDate(startDate, endDate,
allOrderData, customerData, orderData);
Für externe Datenquellen wie Datenbank oder Dateien ist dieser Ansatz aufgrund des späten Filterns zumeist nicht geeignet, da er zu viele Daten lädt. Wir wollen ja nicht die gesamte Datenbank in den Speicher laden, nur um dann ein paar Datensätze zu verwenden. Für kleine Datenmengen oder Daten, die bereits im Arbeitsspeicher liegen, ist dies aber eine gute Möglichkeit, um die Komplexität zu reduzieren.
Ergebnis
Was haben wir nun mit den Refactoring-Schritten erreicht? Unsere Codebasis ist erheblich kleiner geworden, fast alle Interfaces konnten entfernt werden.
Der Dependency Graph zeigt deutlich weniger Linien. Die Anzahl der Codezeilen hat sich auf 715 (ca. 25 % weniger) reduziert, die Zahl der Dateien auf 34 (ca. 35 % weniger). Die Average Component Dependency ist von 5,3 auf 3,6 gesunken.
Neben den reinen Zahlen ist aber wichtiger, dass der Code nun einfacher zu verstehen und nachzuvollziehen ist. Man muss nicht mehr nach Interfaces und potenziellen Implementierungen suchen, um das Laufzeitverhalten nachvollziehen zu können.
Der gesamt Quellcode ist auf GitHub verfügbar.