Skip to content

Instantly share code, notes, and snippets.

@Arnauld
Forked from gregoryyoung/gist:3665514
Created July 17, 2014 21:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Arnauld/3693fe57f624a587084a to your computer and use it in GitHub Desktop.
Save Arnauld/3693fe57f624a587084a to your computer and use it in GitHub Desktop.
Handlers
In Domain Driven Design, there are a series of small services that sit over the top of the domain. These services known as Application Services act as a facade over the the domain model. In CQRS based systems a similar pattern is used but it has been slightly refined. The refining of this pattern can be employed successfully in both systems that use CQRS and systems that stay using a single model for supporting reads and writes as many of the advantages come from simplicity of composition. To show Command Handlers, it is best to start with a typical Application Service and refactor our way to a Command Handler.
The Application Service
A stereotypical Application Service represents a given use case of the model. The Application Service itself generally does not directly implement the use case but instead coordinates objects from the domain to meet the requirements of the use case. This sounds like a small distinction but it is a very important one, Application Services generally should not have "logic" inside of them, instead they should be coordinating objects and the logic should live within the model. If you end up with a lot of logic in your Application Services you likely will an Anemic Domain Model.
[TODO Cite directly definition of an Application Service from Domain Driven Design?]
An Anemic Domain Model occurs when you have a model that "looks and smells like a domain model", that is it has a lot of structure, but when you start searching for behaviour in the model your efforts come up fruitless. Martin Fowler has defined the Anemic Domain Model for some time [citing:bliki]. An Anemic Domain Model can be described as being Transaction Script [PEAA] over a simple structure "domain model". An example of what an Application Service might look like in an Anemic Domain Model can be seen in Listing 1.
[TODO Quote directly from Fowler?]
public class InventoryItemDeactivationService {
public InventoryItemDeactivationService(IInventoryItemRepository repository) {
_repository = repository;
}
public void DeactivateInventoryItem(Guid id, string reason) {
var item = _repository.GetById(id);
if(!item.Activated) throw new InventoryAlreadyDeactivatedException();
item.Activated = false;
item.ReasonForDeactivation = reason;
}
}
[Listing 1, Application Service from an Anemic Domain Model]
[Side Discussion]
It is however important to remember that an Anemic Domain Model is not an anti-pattern in all circumstances. An Anemic Domain Model can be a pattern, I have seen many teams employ an Anemic Domain Model quite successfully. An Anemic Domain Model is only really an anti-pattern if you actually thought you were really building a domain model. Domain models are in their core behavioural concepts but there are many cases where using a domain like data model with many of the patterns you would tend to find in a domain model can be extremely successful. An example of when you might want to consider such a path is when you are lacking Object Orientation skills on the team and are building relatively simple projects. In these cases the domain model can provide relatively good abstraction from data sources, enabling testing amongst other things.
In consulting for companies hoping to do Domain Driven Design around the world, the first thing I tend to look at when seeing their systems for the first time is their Application Services. At a minimum half look like the Application Service in Listing 1, it is a very common mistake. I can identify that they have built an Anemic Domain Model by looking for a few features in the service code. The first feature I see is that they have an If() statement that is throwing an exception, what makes it even worse in this particular case is that the subject of the predicate is the domain object, via a getter. This shows that the InventoryItem is not encapsulating its state.
The next feature related to the first is that the Application Service is calling a Setter on the domain object. Again this is a smell of encapsulation being broken. Anemic Domain Models as a rule break encapsulation. To avoid breaking encapsulation the behaviour would need to move into the domain object. Once behaviour moves into the domain object, the Application Service will look quite different as can be seen in Listing 2.
public class InventoryItemDeactivationService {
public InventoryItemDeactivationService(IInventoryItemRepository repository) {
_repository = repository;
}
public void DeactivateInventoryItem(Guid id, string reason) {
var item = _repository.GetById(id);
item.Deactivate(reason);
}
}
[Listing 2, Application Service in non-Anemic Domain Model]
In this Application Service, a very common pattern is followed. The Application Service consists of finding an Aggregate from a Repository and calling a behaviour on the Aggregate Root. This allows the Aggregate to encapsulate its state. It is important to remember from previous chapters, the core responsibility of an object is to encapsulate state and provide behaviours. Most Application Services will follow the template shown in Listing 2. That is they will load up an Aggregate and call a behaviour on the Aggregate Root passing any dependencies the behaviour may need.
Dependencies are an important reason for having Application Services. Generally an Application Service will expose all of the dependencies needed for a given system level behaviour / use case. I rarely use a container for anything related to the domain model except for the Application Services. In other words, the Application Service exposes the dependencies that will be needed and they get injected to the service which then passes them forward, I do not tend to use a container for anything within the model itself, the Application Services get the decencies and pass them forward as can be seen in Listing 3.
public class InventoryItemDeactivationService {
public InventoryItemDeactivationService(IInventoryItemRepository repository, IDriversLicenseLookupService _driversLicenseLookupService) {
_repository = repository;
}
public void DeactivateInventoryItem(Guid id, string reason) {
var item = _repository.GetById(id);
item.Deactivate(reason, _driversLicenseLookupService);
}
}
[Listing 3, Application Service with dependencies]
In Listing 3, the Application Service is also passing through a Drivers License Lookup Service to the Inventory Item Aggregate's Deactivate method. Don't ask me why the object would need a Drivers License Lookup Service to Deactivate an Inventory Item its just a make believe dependency to illustrate the point! However having worked on lots of systems I can assure you that weirder use cases have existed.
Another responsibility of Application Services aside from pushing dependencies in is to be a place where many cross-cutting concerns are applied. Logging, transaction handling, and authorization are all examples of such cross-cutting concerns. Many people [citing] will use an Aspect Oriented Programming tool in order to weave these cross-cutting concerns into their Application Services.
Moving to a Command Handler
A Command Handler has all of the same responsibilities of an Application Service but is structurally a bit different. As we will see these structural differences will allow quite a few opportunities in terms of how we write and manage our code. To start with we will do a very simple refactor of our existing Application Service; we will refactor it's parameters to an object.
public class InventoryItemDeactivationService {
public InventoryItemDeactivationService(IInventoryItemRepository repository) {
_repository = repository;
}
public void DeactivateInventoryItem(DeactivateInventoryItemParameters parameters) {
var item = _repository.GetById(parameters.Id);
item.Deactivate(parameters.Reason);
}
}
public class DeactivateInventoryItemParameters {
public Guid Id;
public string Reason;
}
[Listing 4, Application Service with parameters refactored]
Listing 4 shows the result of our first refactoring of the Application Service, the parameters to the method have been refactored into an object. We will now pass an object to the method as opposed to the method having N parameters. Most refactoring tools support this refactoring. This "parameter" object will become our Command as was discussed in [Introduction to Commands]. To make it our Command, we will need to rename it as Commands are always in the imperative tense, we will rename it to DeactivateInventoryItemCommand.
This process can be followed for all of our Application Services. We take the method, extract parameters to object then rename the object to something in the imperative tense creating our command. Once we have renamed the parameter object to a Command, the Application Service becomes a Command Handler. A Command Handler has the same responsibilities of an Application Service but will always take a Command as its only parameter. This may seem like any extremely simple refactor but as is usually the case the simple thing is extremely powerful.
Previously all of our Application Services had different interfaces. One might be F(Guid, string) : Unit while another could be G(Guid, string, int, Address) : Unit. We could not build a common interface that represented an Application Service. Once we apply the simple refactor above all of our Application Services, they become Command Handlers and now can meet a common interface as shown in listing 5.
public interface Handles<T> : where T : Command {
void Handle(T command);
}
[Listing 5, A common interface (Command is just a marker interface on Command objects)]
public class DeactivateInventoryItemCommandHandler : Handles<DeactivateInventoryItemCommand> {
public DeactivateInventoryItemCommandHandler(IInventoryItemRepository repository) {
_repository = repository;
}
public void Handle(DeactivateInventoryItemCommand command) {
var item = _repository.GetById(command.Id);
item.Deactivate(command.Reason);
}
}
public class DeactivateInventoryItemCommand {
public Guid Id;
public string Reason;
}
[Listing 6, The Command Handler and Command]
Having a common interface will open a new world of possibilities and make many things that were previously hard quite simple. As we discussed previously a primary responsibility of Application Services is as a place to handle cross-cutting concerns such as authorization, transaction handling, and logging. Many use an AOP (Aspect Oriented Programming) framework in order to weave these concerns into an Application Service Object. The reason they need to use a complex concept like AOP is that there is no shared interface between all of the methods. Now that we have moved to Command Handlers, we have a common interface and the need for a complex concept such as AOP based interception goes away. Instead simple composition on our common interface will provide the ability to address these cross-cutting concerns.
Compositional Handlers
Our simple interface should be very easy to compose. Things that are often Aspects in systems can easily be done through composition if they are applied to things sharing a common interface. All of the cross-cutting concerns that are commonly found as responsibilities on Application Services can be implemented in this way. The way to implement them is to build up a Pipeline. To see how this works, let's go through a quick canonical example, logging.
public class LoggingHandler<T> : Handles<T> where T : Command {
private readonly Handles<T> next;
public LoggingHandler(Handles<T> next) {
_next = next;
}
public void Handle(T command) {
MyLoggingFramework.Log("Begin processing of " + command);
_next.Handle(command);
MyLoggingFramework.Log("Successfully processed " + command);
}
}
[Listing 7, A Compositional Logging Handler]
The LoggingHandler follows the normal pattern of a Compositional Handler. It takes as a generic parameter T which is the type of command that it handles. It then takes a Handles<T> in its constructor which is the next item in the chain after it is called. It also implements the Handles<T> interface itself and can hook that call to put code that it wants into the pipeline. In terms of "weaving" it does the equivalent of a Before and an After aspect. It prints before it calls _next.Handler and it prints after it calls _next.Handle. In some cases you may even want to make this two distinct handlers.
This pattern will likely look familiar to you, it is used in many places. Structurally we could call this a Pipeline, a Decorator, or a Proxy. All of these patterns share composition at their root, this handler is just composing the Handles<T> interface and allowing us to wrap functionality on to existing implementors of the Handles<T> interface.
Another common use of a Compositional Handler would be to handle exceptions that may occur in the processing of a Command. Exception Handling is definitely a cross-cutting concern and we don't want to have our Command Handlers littered with exception handling code that acts as a Logger for exceptions. Listing 9 shows a Composite Handler that can catch Exceptions and write them into the log, rethrowing them for something further up in the chain to be able to respond to.
public class ExceptionLoggingHandler<T> : Handles<T> {
private readonly Handles<T> next;
public LoggingHandler(Handles<T> next) {
_next = next;
}
public void Handle(T command) {
try {
_next.Handle(command);
}
catch(Exception ex) {
MyLoggingFramework.LogException(ex);
throw new CommandHandlerException(command, ex);
}
}
[Listing 8, A Compositional Hander that logs exceptions]
The ExceptionLoggingHandler again follows the general template of a Compositional Handler. It has a generic argument of T that is a Command. It will then implement Handles<T> and take a Handles<T> through its constructor that it then composes. The only difference from the previous example is in it's Handle in that it wraps a try/catch around the next handler as opposed to doing an operation before or after. These Compositional Handlers can be used to abstract any cross-cutting concern so that we don't end up littering our Command Handlers with these concerns. Later in the book we will see a few other examples of how we can use Compositional Handlers to provide merging behaviours to better deal with concurrency issues.
To add logging behaviour to any existing Command Handler such as our DeactivateInventoryItemCommandHandler we would simple have to pass the Command Handler to the constructor of the LoggingHandler which is itself a Handles<T>. This process can be seen in Listing 9.
var handler = new LoggingHandler<DeactivateInventoryItemCommand>(new DeactivateInventoryItemCommandHandler());
//we can also compose multiple
var handler = new ExceptionHandler<DeactivateInventoryItemCommand>(
new LoggingHandler<DeactivateInventoryItemCommand>(
new DeactivateInventoryItemCommandHandler(new Repository())
)
);
[Listing 9, Manually Building a Pipeline]
Multiple level handlers can be built in the same way as seen in Listing 9. It is generally not common however to build manually your command handlers. Doing so often requires a large amount of redundant code as you will often want all or most of your Command Handlers to have a certain behaviour wrapped on to them. It is also generally not a good idea to manually build up all of your chains because you will need to add manually a new line of code for every Command Handler that you want to build. Instead most people will write a simple piece of reflective code to do this for them.
The piece of code do do this is relatively easy to imagine. You use reflections to go through a given namespace or assemby/jar depending on your project/platform and you find all of the Command Handlers. You would then register each with a Container to be able to resolve dependencies and then build up Compositional Handlers on top of Command Handler in order to decorate the additional functionality that you want. I use the term decorate here though it could mean you are building a proxy as opposed to a decorator as they differ in the intent of the Compositional Handler. It is even possible to use Attributes or Annotations in this process to define which Compositional Handlers should be added onto a given Command Handler as can be seen in Listing 10.
[Transactional]
[Logged]
[RequiresPermission("Admin")]
public class DeactivateInventoryItemCommandHandler : Handles<DeactivateInventoryItemCommand> {
public DeactivateInventoryItemCommandHandler(IInventoryItemRepository repository) {
_repository = repository;
}
public void DeactivateInventoryItem(DeactivateInventoryItemParameters parameters) {
var item = _repository.GetById(parameters.Id);
item.Deactivate(parameters.Reason);
}
}
[Listing 10, Attributes to define Compositional Handlers]
The attributes/annotations define which Compositional Handlers should be added to the Command Handler and in what order. To build a small piece of reflective code to handle this is a relatively easy process. I often use it as an exercise in classes and students can complete it in less than one hour. I am leaving a concrete example out of this chapter as it is unfortunately more of an exercise in reflections provided by your particular platform than anything else. I have however placed a full example in C# on my gist.
It is also important to recognize that this is probably not a good place to introduce a framework. I am sure my bias against frameworks has leaked significantly into this text but the reasoning is relatively simple. Making the code that does the reflective analysis and creation of pipelines flexible enough to be general purpose will introduce so much complexity to the code that it will take you longer to learn how to configure the code to do exactly what you want than it would take you to download someone else's code that does something similar and modify it to suit your own purposes. This is unfortunately a common situation. In this particular case, the code to do the reflective analysis is only about fifty lines of code in three methods where as the last framework I saw had over 10 interfaces in it for making the code general purpose.
When in Rome
It is funny that we as developers often try to cling to one methodology in a language even when there is a better way of doing things. Up until now Command Handlers have been implemented as classes. There are other ways of implementing Command Handlers that offer a more concise expression of the same idea. I have a hint that I teach when dealing with object oriented code in a hybrid object oriented/functional language.
If you find an interface containing a single method, it should probably not be an interface, instead it is likely a function.
There are times where an interface with a single method can be a preferred way of doing things but it is not the normal case. Having a class with an interface can make things easier to deal with if you are dealing with reflections as it is easier to reflect over objects than it is to reflect over functions but often times this can be worked around with other methods.
Command Handlers become very interesting when the switching from an interface with a single method, Handles<T> to a function occurs in languages that support it. For Scala/F#/Haskell/Lisp developers this switch will feel very natural, for C# developer it should feel very natural, and well for the Java developers out there you can skip this part or you can try out Scala as Java does not support first class functions.
At the first level we can simply get rid of the Handles<T> interface, leaving everything else in tact. Instead of dealing with the Handles<T> interface the code can deal with a Func<T, Unit>. C# developers may find this part odd as they would prefer to deal with an Action<T> but save yourself a bit of headache and make a Unit type that represents a function that returns nothing instead of having void returning methods, you will thank me later.
The dispatcher would then just allow us to register Func<T, Unit> as opposed to use registering a handler as a Handles<T>. At this point, the command handlers are still defined in the context of classes and you could still use your container with them if you wanted to. But you probably don't, there are better ways of doing this in more functional code which is why I am skipping an example of this hybrid bastardization of code.
The general object oriented methodology can be seen in Listing 6. The dependencies are passed into the constructor of the DeactivateInventoryItemCommandHandler, specifically the IInventoryItemRepository. The dependency is then saved in a private field and utilized by the Handle method. The dependency has a life-cycle that matches that of the instance of the DeactivateInventoryItemCommandHandler.
Functional languages also have the concept of "Dependency Injection" they just do it differently than object oriented languages tend to do it. In a functional language instead of defining two methods, a constructor and the behavioural method only a single method will be created that includes all of the dependencies for that particular method. The transition of the DeactivateInventoryItemCommandHandler from an object oriented to a functional style can be seen in Listing 11.
[Maybe add a bit more on Partial Application like another example first before using on the Deactivate Inventory Item]
public static Unit DeactivateInventoryItem(IInventoryItemRepository repository, DeactivateInventoryItemCommand Command) {
var item = repository.GetById(command.Id);
item.Deactivate(command.Reason);
return Unit.Value;
}
[Listing 11, the DeactivateInventoryItemCommandHandler in a functional style]
The dependency is just added as a parameter to the method and the return value has changed from void to Unit. Again for those not familiar with the concept of "Unit" just consider it to be an object that gets returned when there is no return value value from a function, in other words the function transforms to nothing.
There is of course now the problem that the "Common Interface" that was created above has been lost. The functions may now have very different signatures varying on the number of dependencies the method has. This problem is taken care of though once the dependencies for the function are resolved which in a functional language is done through a process known as Partial Application.
Partial Application is the process of taking a function F(G1, G2, G3) : T and returning a function F(G3) : T having provided values for to G1 and G2. Said differently Partial Application is providing a specialization of a generalized function. For many this type of transformation is more easily seen in code (Listing 12) than in description.
var applied = x => DeactivateInventoryItem(new InventoryItemRepsoitory(), x);
[Listing 12, Partial Application]
In Listing 12, the function DeactivateInventoryItem that we have been previously using in examples is being Partially Applied. The DeactivateInventoryItem function by itself is a Func<IInventoryItemRepository, DeactivateInventoryItemCommand, Unit> or said in another way DeactivateInventoryItem(IInventoryItemRepository, DeactivateInventoryItem) : Unit. Partial Application will fill in a value for the repository and return a new function with the signature of Func<DeactivateInventoryItemCommand, Unit> or DeactivateInventoryItem(DeactivateInventoryItemCommand) : Unit.
In other words the Partial Application process allows us to take a function F that has n arguments with n-1 being dependencies and the other being the command and return a function f1 that has all the dependencies filled in and only the last argument which is the command. This process allows us to provide "dependency injection" and also brings us back to our common interface. Our common interface is a Func<T, Unit> where T:Command. This is an equivalent interface to the Handles<T> that was looked at previously. All of the code operates now on Func<T, Unit> where T:Command as opposed to operating on Handles<T>, things like Compositional Handlers all are still possible but done through functional composition as opposed to object composition. This transformation leads to much more compact code with far less "noise" introduced by the language as can be seen in the rewriting of the LoggingHandler in Listing 12 and comparing with the original LoggingHandler in Listing 13.
public static void LoggingHandler<T>(Handler<T> next, T Command) {
MyLoggingFramework.Log("Begin processing of " + command);
_next.HandleIfNotNull(command); //The Maybe Monad could be used here as well.
MyLoggingFramework.Log("Successfully processed " + command);
}
[Listing 12, Logging Handler using functional Composition]
public class LoggingHandler<T> : Handles<T> where T : Command {
private readonly Handles<T> next;
public LoggingHandler(Handles<T> next) {
_next = next;
}
public void Handle(T command) {
MyLoggingFramework.Log("Begin processing of " + command);
_next.Handle(command);
MyLoggingFramework.Log("Successfully processed " + command);
}
}
[Listing 13, Original Logging Handler]
As you can see, quite a bit of code that was offering little value disappeared. It hurts my eyes much less to read Listing 12 than Listing 13 and if you are in a functional or a hybrid functional-object-oriented language this is definitely a strategy that you should consider employing as the two listings are functionally equivalent just expressed differently.
Another benefit to using this style of dependency resolution is that for many scenarios it can end up being much simpler than dealing with a container. Consider what a setting up dependencies could look like in a simple scenario using just code to resolve dependencies.
public void Bootstrap() {
dispatcher.RegisterHandler(x => Handlers.DeactivateInventoryItem(r => GetRepository(), x));
dispatcher.RegisterHandler(x => Handlers.CheckOutInventoryItem(r => GetRepository(), x));
dispatcher.RegisterHandler(x => Handlers.CheckInInventoryItem(r => GetRepository(), x));
dispatcher.RegisterHandler(x => Handlers.CreateNewInventoryItem(r => GetRepository(), x));
}
[Listing 12, Using code to initialize dependencies]
This is the initialization of four Command Handlers including the resolution of dependencies. If you were not to have too many nested dependencies and a reasonable number of Command Handlers, this could be a very reasonable (and simple) way of resolving dependencies for your model. There is no container being used just a bit of code to setup dependencies. This is an important tool to keep on your tool belt. IOC (Inversion of Control) Containers are great, especially when dealing with many nested levels of dependencies but they are also vastly over complicated for many scenarios. When dealing with dependencies for the domain model, the objects inside don't live in a container. The object inside get their dependencies generally through the Application Service or Command Handler. These dependencies most commonly do not have a large amount of nesting. Partial Application can work extremely well for these circumstances and can avoid the need for an IOC Container.
Multidirectional Composition
So far different ways have been discussed about how to compose the handlers themselves using the interface Handles<T> or its functional equivalent of Func<T, Unit>. It is important to remember that often times when dealing with Envelopes you want to return information back up the chain in which case the interface would be Handles<T,V> or Func<T,V>. The use of Unit really shines here as the same code can be used both on a method with a "void" return type of on a method that has a return type as they are both Func<T,V> or Handles<T,V> except when void it will return Unit. This is however not the only type of composition that can be utilized when dealing with handlers. Another for of composition exists by composing the T that is also extremely useful.
//TODO is say a RESTful example a better example?
public class WithTimingEnvelope<T> : Message where T:Message {
public readonly T Payload;
public readonly TimeSpan Duration;
public WithTimingEnvelope(T payload, TimeSpan duration) {
Payload = payload;
Duration = duration;
}
}
[Listing 13, A very simple envelope]
Basically the WithTimingEnvelope<T> class is a Message. This means that it shares the same common interface as the common interfaces discussed above and can therefore have all of the compositional handlers used on it. It also contains a Message as T is constrained to be a Message. If we were to write a generic envelope interface it would look like Listing 14 implementing the Message marker interface and containing a payload which also implements the Message marker interface.
public interface Envelope<T> : Message where T:Message {
public T Payload { get; }
}
[Listing 14, Generic envelope interface]
public class DurationWritingHandler : Handles<WithTimingEnvelope<T>> {
public DurationWritingHandler(Handles<T>
The envelope pattern is generally used in conjunction with Composite Handlers as can be seen in Listing 15. This pattern allows the use of the Composite Handlers to require additional information on the Message and to be able to compose it as opposed to having to do things such as adding them to the Message marker interface. As seen in Listing 15, the Compositional Handlers normally come in pairs. One that will enrich the information, the other that
Summary
Many important things have been discussed in this chapter. The Application Service pattern from Domain Driven Design has been introduced. Application Services act as a facade over the top of the domain coordinating domain objects in order to provide a behaviour/use-case. An issue however exists in Application Services as they do not share a common interface and as such often end up with code bloat.
A simple refactoring solved this issue by refactoring the parameters of the Application Service method to a Parameter Object. This object was then renamed to the imperative tense and became a Command. This process moved the code from being an Application Service to a Command Handler, all Command Handlers meeting the interface of Handles<T> where T:Command. Command Handlers because they share a common interface have many tactical opportunities in code that can be missed when dealing with Application Services, including the ability to write Compositional Handlers.
[Picture of a little wizard etc?]
The use of a common interface and the ability to chain together small composite handlers gives the ability to handle cross-cutting concerns without the use of something such as an AOP (Aspect Oriented Programming) framework that allows interception. Interception can be achieved through simple composition. This is quite an important concept as the need for a dependency on a framework has been shed. AOP frameworks can be particularly nasty to bring into most projects as they have a large amount of "magic" associated with them. Often times these frameworks are implemented through the generation of runtime proxies or through post-compile static weaving. Both of these mechanisms are extremely complex from a junior's perspective and often they will not integrate well with debugging processes. This is not to say that all have problems with debug-ability, but many do and even the ones that don't still have a huge amount of magic happening internally in order to get things working. As a rule, I try to avoid frameworks where possible, especially where a simple change allows me to achieve what I wanted to do with them.
Partial Application was then introduced to allow a more compact way of expressing Command Handlers and Compositional Handlers. Partial Application is the specialization of a more generic function and can act as a way to implement "dependency injection" in functional code. The introduction of Partial Application can also remove the need to maintain an IOC (Inversion of Control) Container in many systems while still maintaining clean, well factored, testable code. As with the AOP framework the removal of the framework also removes a fairly large dependency. It is again my personal preference to prefer simple solutions without a dependency on a framework, especially when it is relatively simple to avoid one and I can get other benefits at the same time.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment