So this is a long blog article with lots to cover and why it leads me to writing the Apie library.
My personal background
So when I was still on university, I never wanted to work in the web development, but I wanted to make videogames as my professional career. I started in a startup, but quickly realised this was not the career I wanted. However there were quite a few things I learned that I still use on a daily basis in how I design a web application.
In game development you make a game with a game engine. The analogy with web frameworks is easy to make: you can make your own engine, but for most companies it's easier to use an existing one. A game engine however has a few differences compared to web frameworks. The game engine I used in the startup was the Source Engine famous for Half-Life 2. So I get some lessons there:
- A game engine makes some very rigid choices that comes with large trade-offs. For example in the Source Engine shadows are precalculated when you save the level in the level editor. This means the game has lower system requirements to run, but at the cost of a more static level. It requires creativity to work around some.
- A game engine always focuses on a specific type of game and any other type of game needs to build their own type of logic above it. For example the Source Engine is made for 3D first-person shooters. Do you want to make Super Mario 64 with it? It is possible, but you need to build this atop yourself with custom coding.
- The game engine is really stupid. All it does is load a level and handles some static asset loading and processing user input. But it does this very well and efficient.
- In most games you make all your game objects their own classes. For a game developer it's common sense to have a class Goomba in your code if you are making a Mario game that has all the logic of a Goomba in it.
Starting in web development
So when I started in web development the biggest hurdle at the start of my career was understanding MVC and why it is beneficial in web application. So why was MCV illogical to me? I think it's best by just showing how the 2 biggest frameworks in PHP do it:
An example of MVC in practice: Laravel
So how does a general controller for a REST API in Laravel looks like? Laravel uses PHP arrays for communicating. You validate with a form request that returns an array, you modify the values in the controller and you return a Laravel JSON resource with an array.
class ExampleController
{
public function store(ExampleCreateRequest $request)
{
$example = new Example($request->validated());
$example->save();
return new ExampleResource($example);
}
}
Looks simple, but the logic of your object is split in ExampleCreateRequest, Example and ExampleResource.
ExampleCreateRequest creates an array of 'rules' with magic strings to validate, because PHP developers love their magic strings:
class ExampleCreateRequest extends FormRequest
{
public funcion rules(): array
{
return [
'firstName' => 'required|string|min:1|max:128',
'lastName' => 'required|string|min:1|max:128',
]
}
}
And ExampleResource is a class that will tell Laravel how to encode it to JSON with magic __get properties.
class ExampleResource extends JsonResource
{
public function toArray(): array
{
return [
'first_name' => $this->firstName,
'last_name' => $this->lastName,
];
}
}
So if I want to read all the business logic I need to put my attention to 3 classes at the same time. If I skip the form requests I can bypass the validation checks and save something with a first name of 129 characters. If I add or modify a field I need to add/change it in 3 places. So if I make a console command that does the same action, I have to write it again, but then use a different interface for the validation. It gets even worse if you have a REST API with a SPA as you often need to repeat it even more times.
An example of MVC in practice: Symfony
How about Symfony? In Symfony you see more usages of using Plain Old PHP Objects (POPO) with attributes and the common ORM is a data mapper (Doctrine), not Active Record.
class ExampleController
{
public function __construct(
private readonly Serializer $serializer,
private readonly ValidatorInterface $validator,
private readonly EntityManagerInterface $entityManager,
) {}
public function store(Request $request)
{
$example = $this->serializer->denormalize($request->toArray());
$this->validator->validate($example);
$this->entityManager->persist($example);
$this->entityManager->flush();
return new JsonResponse($this->serializer->normalize($example));
}
}
This looks like more code, but in general we would move the serializer and validator part to a controller argument value resolver and would move the return value to a subscriber to the kernel.view event. If we would do that the controller becomes practically empty!
class ExampleController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
public function store(Example $example)
{
$this->entityManager->persist($example);
$this->entityManager->flush();
return $example;
}
}
And congratulations, you have just made a variation of ADR (action, domain, responder) and a copy of the first version of the API Platform! So in Symfony most business logic is often found in the domain object in the form of PHP8 attributes and services read them to run some very generic code:
class Example
{
public function __construct(
#[Length(1, 128)]
#[ORM\Column('varchar')]
#[Required]
private string $firstName,
#[Length(1, 128)]
#[ORM\Column('varchar')]
#[Required]
private string $lastName,
) {}
}
At least it's in a single place now and I have full type completion! But I can still bypass the validation part, by just not calling the validator. In fact I need to create the object before I can validate my input, which means this can never be a domain object. In this instance it's very common to see like hundreds of attributes and you have to find out where it is being used.
No matter what form of framework, if you want to use domain objects and end up with an architecture like hexagonal or CQRS you are basically making Super Mario 64 in the Source Engine. With basic MVC it's very common that business logic is split.
My goal
So I want to have one domain object that contains all my logic and pick an architecture that reflects a game engine more. Want to have a Rest API? Just add a "Rest API" game engine around your domain object. The domain object should be the only thing you make just like you would make a Goomba class if you make a Super Mario Bros. game.
- if a user is a domain object, it should just have an activate method to activate an user.
- domain objects can only reference other domain objects if they are a root aggregate.
- a domain object should not care how it is being stored and in what format
- domain objects can not be in an invalid state, so heavy usage of value objects is required.
How to get there?
Specify how to make a domain object
To get there I first started with just thinking how a domain object should work in PHP and eventually I ended up with a spec over being able to configure everything manually. This is very opinionated but it makes the scope of a magic "game engine" that poops out a Rest API more doable as you can make more assumptions.
- So we have value objects that implements ValueObjectInterface with method fromNative and toNative. Most are string value objects.
use Apie\Core\ValueObjects\Interfaces\HasRegexValueObjectInterface; use Apie\Core\ValueObjects\IsStringWithRegexValueObject final class DatabaseText implements HasRegexValueObjectInterface { use IsStringWithRegexValueObject; public static function getRegularExpression(): string { return '/^.{0,65535}$/s'; } }
- We have entities that implement EntityInterface. It should just have a getId method that returns a special value object that also tells what domain it is referring. We call these identifiers and is how we reference other domain objects.
use Apie\Core\Entities\EntityInterface; final class ExampleEntity implements EntityInterface { private ExampleEntityIdentifier $id; public function __construct(private readonly DatabaseText $readonlyDescription) { $this->id = ExampleEntityIdentifier::createRandom(); } public function getId(): ExampleEntityIdentifier { return $this->id; } // PHP 8.4 property hooks work as well... public function getReadonlyDescription(): DatabaseText { return $this->readonlyDescription; } }
- We can not use arrays as you have array being used as sets, lists or hash maps and want to distinguish what it contains.
use Apie\Core\Lists\ItemList; class StringOrIntList extends ItemList { public function offsetGet($offset): string|int { return parent::offsetGet($offset); } }
- Data Transfer Objects are also added for transferring large pieces of structured data with little business logic.
use Apie\Core\Dto\DtoInterface; class ExampleDto implements DtoInterface { public int $integer; public DatabaseText $longText; }
- File uploads could use the PSR standard for uploaded files.
- Enums were native in the language, so I also used them like this.
So for example I made value objects for phone numbers, password value objects and date value objects. Even if I fail to ever make a auto-generated CMS/API library I would still have at least some reusable value objects.
I also made my first "Game engine' in the form of apie/faker that uses reflection to make random instances of a class. Faking data is good for seeding your database so you application is less 'empty' in development.
The first challenge: a Rest API
So I took the API Platform as a basis of how it should work. I know API platform is terrible for non-resources API points, so I added the concept of actions where it is just input/output. I wanted a good generated OpenAPI spec, so this was also one of the first challenges:
To make sure the OpenAPI spec is correct I made sure the integration tests were not only testing API calls, but also validating the generated OpenAPI spec was correct. I had to build the OpenAPI generator from scratch as the only OpenAPI generators were not compatible with Apie. The one from API Platform is hardly extendible and library zircote/swagger-php is basically writing large pieces of Swagger PHP attributes which is to me an inner platform-effect.
The second challenge: a CMS/Admin Panel
The next one I had in mind is a generated admin panel. In general most customers want to be able to view or edit their data, but calling a SwaggerUI endpoint is sometimes a little bit too complex for most of them. Often you add something like a headless CMS like Strapi or Directus or something like Laravel Nova, but all of them require you to write new controllers to make it work. Wouldn't it be better if just including a single Composer package will give you an Admin Panel for free?
Making a CMS Admin Panel is quite complex besides all the obvious permission checks and keeping it generic. The UX should be pleasant to work with and it should be possible to have multiple layouts. I'm still improving the interface as it could still have some work:
Extracting common part
I also noticed I was repeating myself since there was no common metadata concept when I worked on apie/console. So every 'game engine' was making their own reflection login and route generations. Instead of let it the package handle it himself all packages should use the same metadata logic. I ended up with apie/common with all shared information combined and apie/core for only building domain objects. This transition went quite smoothless and I found many bugs and quirks in this phase.
The database data layer part
Oh boy, this one was fun to make! I actually needed every grey brain cell to get this one done. I wanted a no-configuration saving to the database, so no Column attributes or anything. It should also run an optimized GET query if I use filters. When building it I noticed I was writing my own version of Doctrine as I ran into problems like the order of saving objects is important(yes, almost NO programmer is aware of this detail). So I decided to make a tool that converts a domain object into a Doctrine entity instead and let Doctrine handle all the transaction and migration logic.
I rewrote this part completely twice before I was happy enough.
I also wanted to be able to do a text search using TF/IDF. TF/IDF is the algorithm Google used in their first search engine and it can be done very easily in a database. It also felt awesome to be able to do a full text search on any domain object without the developer having to think about it!
But after I had finished a first version: this is going to change web development! Once you know the spec of Apie you could use it in any application setup! I could even use Apie domain objects in one application and just copy, paste them in an other application.
Trying to build a web application
So now I had a basis I tried to see if I could make a simple application with it. And I noticed the spec was too simple. I could not even make a simple webshop. The reason: you could only provide data from the request body, form submit or console command input. For example:
- How do I provide the currently authenticated user for a "created by" field?
- Permisions were still a bit troublesome as according to domain driven design a domain object should not handle the authorization checks.
- How would multi-language domain objects work? There should be a locale context?
So to my own dismay I decided to add PHP8 attributes to be able to distinguish them and extended the 'Context' part where the context contains all information of the current state. I do want to keep it at an absolute minimum though and with some intuitive system:
- Placing a #[Context] attribute on a constructor argument could provide a 'created by' field. If you are not logged in, you are automatically not allowed unless the argument has a default value. It could also be used for localization on setters.
private UserIdentifier $createdBy; public function __construct( #[Context] UserInterface $user ) { // we only store the id... $this->createdBy = $user->getId(); // we store a copy of the user, probably not what you want $this->user = $user; }
- Placing a #[RuntimeCheck] attribute on methods could indicate whether you can execute a method. Since attributes are custom classes it can be made with business logic names:
#[RuntimeCheck(new IsLoggedInWithRole('ADMIN'))] public function activate()
- Placing a #[StaticCheck] attribute on methods or classes could indicate Apie should not generate an endpoint for specific reasons, for example disable it as a console command, but not as a REST API endpoint. Doing it with RuntimeCheck would result in an endpoint that always throws an authorization error, which is a bit pointless.
- It's very common a user can only see records from the same company etc. To make this work without people having to write custom queries and keepting the logic in the domain object is with an access control list.
So after adding this it was possible to make a fully working web application. I could make a simple webshop in a few hours and also made a simple demo application for storing your geeky collection.
Conclusion
So after the datalayer part I concluded that this could be a new way to do web development.
Almost all business logic is in one place, not hundreds of lines are needed to store and serialize your domain object that could distract a new programmer or solve things in the wrong part of the application.
So the biggest benefits I noticed:
- I have not written documentation, but other programmers can pick this up easily.
- Most were in belief that the code must be buggy, but none were able to find one.
- I can add new features and options in Apie and in most cases it requires no code changes in the application. New features I could think of are XLS exports and GraphQL.
- As a programmer you do not need to know every detail as the cross-cutting concerns are already done in meticulous detail. You get an OpenAPI API without you having to know the intricate details of OpenAPI at all!
- I can copy, paste domain objects between different frameworks. I'm actually considering even adding a Wordpress Plugin.
But for now I conclude that I have the first release candidate release of Apie! The last couple of months there has not been any big changes and it seems to be very stable with a good testing code coverage. I can't wait to have a first stable release in the future!
Comments
Post a Comment