Clean Drupal Codebase Design with Application Services

Drupal 8 has allowed the use of new design patterns through both Drupal core and some well-known contributed modules. The patterns generally deal with Drupal, but what about your business logic?

Drupal 8 has allowed and fostered the use of new design patterns through both Drupal core itself and through some well-known contributed modules. Those patterns are generally oriented to dealing with Drupal the framework, as expected. But, what about your business logic?

Sticking to using just core APIs and concepts is enough to get past the coding standards stage in a codebase. Still, you can take it a bit further and have cleaner business logic that better reflects what's going on in your application. That should also help to avoid what is known as the Big Ball of Mud.

With that goal in mind, a few artifacts from Domain-Driven Design (DDD) were introduced to the Drupal projects. Those artifacts are Application Services and Request/Response Objects. 

Anatomy of an Application Service (AS)

For the sake of simplicity, application services are defined as the classes responsible for controlling the execution flow of our application. They coordinate entities, repositories, other services of your domain, and other infrastructure services.

Another, more intuitive way, is to say that an AS is essentially a use case of the application. If there's a piece of logic that takes some input that is not coming from your domain (e.g., a UUID), does some processing, and persists some data in the database, in one or more entities, that's a candidate to be an Application Service.

DISCLAIMER: Note that in DDD, there's the concept of Domain Services, which are similar to but not quite the same as AS. To keep this post simple and actionable as a single, small step to simplify a Drupal codebase, know that they exist, and are worth reading about as well.

For example, Drupal's BanIpManager class has the following methods:

  • isBanned
  • findAll
  • banIp
  • unbanIp
  • findById

If this were to modeled as custom logic instead of as framework logic, you'd end up creating two different Application Services:

  • BanIpService
  • UnbanIpService

And they both would probably use, via Dependency Injection, a BannedIpRepository

So how do you create an AS? Generally speaking, an AS is a normal class with only the constructor and one single public method. Other methods can be added for internal logic, but the public API will only be one method, like this:

class CreateNewClientWithSubgroups {
  
  public function __construct(Dep1Interface $dep1) {
    $this->dep1 = $dep1;
  }
  public function execute(CreateNewClientWithSubgroupsRequest $request): CreateNewClientWithSubgroupsResponse {
    // do necessary checks before attempting any db changes.
    // throw exceptions as needed.
    // Perform changes and return a response object.
    return new CreateNewClientWithSubgroupsResponse($data1, $data2, $data3);
  }

}

The constructor method doesn't need an explanation and is used to pass the dependencies of our service. The execute one gets a bit more interesting. Notice that it has only one argument, a CreateNewClientWithSubgroupsRequest object, and it returns a CreateNewClientWithSubgroupsResponse object. These will be covered shortly, but first, look at the benefits of this simple change:

  • For starters, we're way closer to the "S" (Single responsibility) of SOLID than if we had placed this service as a method in a larger class.
  • As a consequence, our service is likely to have fewer dependencies, making it simpler to test.
  • With the logic encapsulated, it's easier to understand what's going on during that specific operation that takes place in our domain.
  • From a structural point of view, and specifically for maintaining codebases over a long period, this makes the codebase more expressive: A developer can open a Orders/ApplicationServices/ directory and see at a glance all the different use cases that can happen in the system for orders.
  • It's easier to reuse logic from other clients. Imagine a feature that comes up out of an editorial requirement. In most Drupal projects, you'll see the logic for the operation, mixed into the Form class, as part of the submit handler. Next week, a CI job happens to need that feature too, so another developer has to create a Drush command for it. Chances are that code will be copied and pasted in the command file, instead of reused. But if the logic is modeled as an AS in first place, the same service, already in place and tested, can be called from the command because it's no longer coupled with the web UI (Form API).

Request and Response Objects

Back in the AS, as mentioned, there's only one public method called execute. One significant benefit of that is consistency. Every service or utility (or developer!) making use of AS knows it has the same entry point and is more aware of where to find details regarding what the AS is doing and how it's doing it. Such consistency is particularly useful to make the service work with a Command Bus.*

A more important part of the execute method signature is its arguments. Generally speaking, you want this method only to accept a request object, and return a response object. That object is essentially an immutable Data-Transfer Object (DTO), which by definition, is only used to pass data around. They're generally named after the service itself, with a Request or Response suffix for each one, respectively. A request object would look like this (doc-blocks omitted):

class CreateNewClientWithSubgroupsRequest
{

    private $clientName;
    private $groupName;

    public function __construct(string $clientName, string $groupName)
    {
        $this->clientName = $clientName;
        $this->groupName = $groupName;
    }

    public function getClientName(): string
    {
        return $this->clientName;
    }

    // Other getters...
}

Similarly, the response object would contain the data relevant to the service. Following the example, in this case, it could return the IDs of the subgroups created. Here are a few of the immediate benefits of this:

  • No more array madness. If you've been around long enough in the Drupal world, you might fancy this. If you need to expand your service to use a new parameter, update the DTO signature.
  • For complex scenarios where the request parameters are many or the instantiation changes depending on the context, you get encapsulated logic for those scenarios. With objects, you can resort to having multiple static factory methods on the class, and even declare the constructor as private to make sure developers using it look for the appropriate method. For even more complex cases, a factory class to instantiate request objects can be a good choice as well.

The most powerful property of using this approach comes not only with the clarity provided for purpose-specific artifacts but also with the impact it can have on the overall system. Since these DTOs are typed, they can be easily serialized and stored in a database. This makes for a consistent way to track requests for actions that need to happen within your application but are not required to run in the exact moment they're requested. The class MenuTreeParameters in Drupal 8 core, for example, makes for a good example of a DTO.

It's worth noting that, generally, it might not be a good idea to have static factory methods to instantiate your classes. However, in this scenario, that is perfectly fine because, as mentioned above, we're just dealing with a DTO, which is purely for storage, retrieval, serialization, and deserialization. In short, we'd be using a Named Constructor, not to be confused with service instantiation via static factory methods.

Closing and Trying Things Out

Two of the main tactical artifacts of DDD, Application Services and Request/Response objects, have been covered. With these tools, you can start to simplify the code of our Drupal projects, and shape them in a way that will not only bring about a more expressive codebase but also scalability and performance improvements, if you choose to go that way. 

Architecturally, modeling logic this way is one step forward to decouple it from the Drupal framework. While it might seem of little value if Drupal is the only framework you've worked with, it has a lot of potential benefits as you can eventually get parts of the application that could be placed better in a completely separate application that communicates with the main one via an API, message queues, etc. That allows you to experiment with new frameworks in low-risk areas of your business, or segregate certain logic into separate services that will be maintained by different teams without having to tear the whole thing apart or start from scratch.

If you're curious about how this would look like in your project, try it! Find some business logic that meets any of the following criteria (bonus points if it meets all four):

  • Is in the submit handler of a Form API class
  • Is in one of the ever-present Entity API hooks
  • Is left alone in a Drush command file
  • Is in a .module file, as a standalone function

After you find that piece of logic, create a separate Service class for it. Remember to make it with only one public method, which receives and returns a Request/Response object. That's it. You're done!

DDD consists of a broader range of concepts and artifacts, which can and should be combined following certain rules. Only a few of them are mentioned in this article, but hopefully, they have sparked some interest in you. If that's the case, you might enjoy some of the existing literature around this topic, with books like Domain-Driven Design in PHP (recommended if you're just starting), Domain-Driven Design DistilledDomain-Driven Design, or Implementing Domain-Driven Design

If you're unfamiliar with this pattern, a Command Bus is a way to separate infrastructure actions that are meant to happen alongside the changes the service is performing on the application but are not really part of the domain in which the service is interested. Such actions can be logging of certain events, shutdown processes, opening, and closing database transactions, etc.

Acknowledgments

Links

Published in:

Get in touch with us

Tell us about your project or drop us a line. We'd love to hear from you!