Recently, PHP developers talk a lot about DDD – hexagonal architecture and similar patterns. Their main goal is to separate business logic from a framework, storage and third party related code. I’d like to discuss this topic further and show you how to separate domain stuff from all necessary boilerplate that is needed to have everything up and running. I’ll use two very popular PHP frameworks – Laravel and Symfony, in order to prove that creating a framework-agnostic application is possible, no matter what set of tools you use.
What is a framework-agnostic application?
Let’s start with explaining the basics – being framework-agnostic means that code containing business rules of your application is carefully separated from framework files. As a result, your domain doesn’t know anything about the framework that is used to handle HTTP related communication. But it’s not only about Laravel/Symfony (or any other framework that pops to your mind). I want it to be ORM/third party libraries agnostic too. Moreover, I don’t want my business logic to be bound to Doctrine’s annotations or Eloquent model. It needs to be clean and fully working on its own.
Why separate an application’s business logic from other elements?
You may think: “It’s ridiculous, I’ll never change the framework or the storage in an existing project.” Really? Are you 100% sure? I’ve been working on a project which is being migrated from Zend Framework 2 to Symfony 4. If the application domain had been separated from the implementing part, it would be A LOT easier. We would’ve just needed to switch implementations for a set of contracts and targeted framework-related classes. As a result, it could’ve saved us a lot of time and hassle.
If you don’t touch anything, you can’t possibly break it. Domain code scattered all over framework related classes can be broken easily during migration. Tests won’t help you because you’ll have to rewrite them all (and you can break them, too).
With a framework-agnostic approach, the domain and its tests are completely independent, so you have full confidence that it’ll work fine.
All potential issues will be related to new tools, without any chance that you’ll accidentally change the application’s behaviour.
Even if you can’t imagine changing storage during the project’s life, it’s possible that you’d like to change the ORM or even get rid of it. If business logic models are not separated from storage libraries, this is usually a very time-consuming task. Not to mention, that people often design domain classes with specific storage in mind, rather than focus on their natural relations. Keeping everything separated can even allow you to use two different storage implementations to check which one better suits your needs.
How to achieve it?
It’s not as difficult as it sounds. We will use several techniques to make things easier.
Disclaimer: What I’m gonna use is neither DDD nor hexagonal architecture. We’re focusing on being framework-agnostic, so we will mix concepts from different patterns without using strictly any of them. Finally, my code doesn’t include tests, but they’re omitted on purpose, in order to simplify the whole process.
First of all, when we start designing our application we shouldn’t think about a framework or database at all. Instead, we need to focus on business requirements, its rules and goals. Other things are not that important at this point. When it’s done, we can think about appropriate tools to have it running somewhere on the web. It’s time to introduce the example domain that I’ll use. Please welcome…
The Runner App
It’s a very simple web application which helps out the runner with his run management for a competition that he’s participating in. Easy-peasy. So let’s see how the app works.
We’ve got a Run which has its title, date of start, length and type. We also have a Runner that can participate in a Run and if he finishes it, his Result is saved. For the purpose of this article we’re going to implement simple actions:
- Enrol Runner to Run with the following rules:
- if a Run has already started, a Runner cannot be enrolled
- if a Runner is already enrolled for a Run it cannot be done for a second time
- Save Runner Result in Run:
- if a Runner has not participated in a Run, its Result cannot be saved
- if a Result has expired, it cannot be saved (in our example the result can be added up to five days after the run)
- if a Runner reached time limit for the Run, its Result cannot be saved
- if a Result for a Runner is already saved it cannot be done for a second time
These are probably not all the rules that should be used in this case, but for our example, that’s absolutely enough.
Disclaimer: We don’t care about run creation or runner registration here. The example app is simple and makes some assumptions about storage state (there are already runs and runners inside). I don’t want to overcomplicate this example because it’ll become harder to understand.
Having some business knowledge, we can start programming our domain. Let’s begin with a few models:
This is our Run model. As you can see, it’s very simple, just a few properties with a constructor and getters. You may ask, what Id class is? It’s self-validatable class storing UUID – just take a look. There is also RunType class which stores possible types of runs. However, it’s not critical for our example.
The next one is Runner model:
Here we have a few interesting things to explain:
- Runner model uses User which contains simple properties like email or password.
- We have RunParticipation and RunResult classes here that connect our runner with runs and its results in them. I’ll show you one of these classes later.
- I decided to keep the relation between Runner and Run in the first one (you’re free to do the other way, that’s just my design decision).
- We have two juicy business methods here: participate and result. They’re a core of our system and they’re responsible for almost all business logic in this example. As you can see, they contain a simple set of conditions (which are described by our business rules) and throw exceptions if they’re not met. Other than that, they create a new instance of specific relation class, add it to the collection and return for easier processing by other parts of the application.
I’d like to show you an example of a business exception:
As you can see it’s a very simple class containing a specific message. Please notice, that it extends DomainException instead of PHP’s \Exception directly. That gives us more control and helps to treat domain exceptions in a special way.
Now let’s take a look at one of the relation classes, RunResult:
We will use it from the Runner model’s perspective, so it contains Run. Runner id is needed only for easier integration with other layers of the app. It also holds a time result which runner achieved during the competition.
RunParticipation model is very similar. It doesn’t contain additional properties.
Please keep in mind that RunParticipation and RunResult are still a part of the domain, and they have nothing to do with how it will be implemented in the infrastructure layer with a real storage implementation.
Take a look one more time at all these models. They don’t know anything about the framework, storage or third party libraries. They are pure PHP classes containing the essence of our application.
In the domain, we have not only models, but we also need a way of getting our models from the storage. For this purpose, we’re gonna use the Repository pattern. Example repository contracts, that are still part of our domain, are presented below.
With the first one, it’s only possible to retrieve runner from the storage, the second one just saves his participation. We focus on the important elements only!
Now that we have our domain layer explained, it’s time to take a look at the application. In this layer, we’ll use a command bus, so for every application action, there is class responsible for it. In this pattern, we have a very simple Command class containing data introduced by a system actor. Then, it is handled by a Handler that executes specific logic with domain layer’s help. Let’s take a look at runner’s enrolment example:
The command is self-explanatory. It’s just a use case for our application. The Handler uses domain repositories to fetch required models, execute the specified business logic task and saves its result back to storage. What’s worth noticing? A relation between layers: domain doesn’t know anything about the application, but the application uses the domain to perform its tasks. Also, the application is still third-party-tools-agnostic. We didn’t have to choose a framework or storage!
What about frameworks?
And… that’s it! We have a fully working application. There are only a few small obstacles: it can’t handle HTTP/cli requests, doesn’t have storage, so we have no real data to work with. It’s also missing some other things indispensable for frameworks and third-party libraries to make our life easier.
So, it’s high time to introduce frameworks! But I am pretty sure that you already had enough new knowledge for today, so we will meet frameworks and continue creating out the framework-agnostic app in the second part of this article. SOON TO COME!
In the meantime, you can find the agnostic part of the aforementioned application here.