Building PuluAuth: A Journey in Exploring Hexagonal Architecture - Part 2
Now that we understand the basics of hexagonal architecture and have set some basic requirements and design specifications, we can start actually building the app.
Let’s start small, our first objective will be to create a simple HTTP REST API endpoint, findOneAdministratorByCode
. As the name implies, this endpoint will, you guessed it, find one administrator by code (how original!). We will use this endpoint to set up our application projects before going further.
By the way, all of the code will be available at https://github.com/pulunomoe/puluauth. However, please do note that since this is a very much a work in progress, it might keep changing every now and then. For your convenience, please use the tag part-1 for this article (spoiler alert: it contains more).
Also, maybe I won’t really cover the tests in this article, but they are there, please check the repository to see the test.
The database
A little bit of a disclaimer first. When I design a database, I like to use a separate identity column called code
, storing (sort-of) compressed UUIDv4, in addition to the regular auto-increment ID column. The reason is, that if you only use the standard auto-increment ID and expose it to the user with an endpoint such as:
/administrators/find/1
It does not feel secure to me as it allows the user to guess other administrator IDs, like 2, 3, or maybe something else. However, by adding the aforementioned code column which contains data such as “48TsAWJC3ecna6fQp3UvLr”, it will be much harder for the user to guess the other administrator.
Then why not just use that as the primary key? Well, certainly you can, but I still prefer to keep the auto-increment ID column, because it is sortable and useful for me and other developers to have it internally.
Why not use UUIDv7 or even ULID? Good point! You certainly can, but, for me, I think it is easier to see and understand the order of data creation with auto-increment int such as 1, 2, and 3 rather than 01JB8FZ40WJR4PAEQXZY569Q7H, 01JB8FZAT9TBJ97WBWQHHGQ424, 01JB8G1FHJQFVG4FDYRSXF06DC.
Why don’t you just add a created_date field then? Another good point! And I do add that field. But then again, I just like the convenience of auto-increment int. Again, this is just a personal preference.
Back to the database, let’s start simply, we will create the bare minimum just to satisfy our first objective:
CREATE TABLE administrators
(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
api_key VARCHAR(255) NOT NULL UNIQUE,
secret_key VARCHAR(255) NOT NULL UNIQUE
);
CREATE VIEW administrators_view AS
SELECT id,
code,
name,
email
FROM administrators;
Wow! Unsigned bigint for the ID! Hey, what if we will have two billion administrators? Who knows? Storage is cheap anyway. How many gigabytes do you have in your phone again?
Why the view? The main reason is for consistency, when retrieving administrator data for viewing, we don’t want to accidentally expose the sensitive data. We can prevent that by simply selecting from this view.
The domain
The title says this article talks about Hexagonal architecture, but we will also mix up several different architectures and patterns, most prominently, we will also use the Domain-driven design architecture. Another thing that I’m also not familiar with and trying to learn here too!
In my understanding, the domain is essentially the Model in MVC, it is a place where you define entities and their business logic. One notable difference here is that each entity will have its own identity object, which should be immutable once they have been set. Why? Imagine you booked an airline ticket, which has the flight and seat number as the identity, imagine the chaos (fun?) if your ticket identity changed midway through the flight.
Anyway for the domain itself, we will create seven classes. Remember my ramblings about creating a gazillion class? Now you see what I mean. But don’t worry! It (hopefully) will make sense in the end, at least that’s the case for me.
The administrator code identity
<?php
namespace App\Domain;
abstract readonly class Code
{
public function __construct(private string $value)
{
}
public function getValue(): string
{
return $this->value;
}
}
This is the base class for all of our entities’ code identity. Here, we set a rule where the $value
variable is read-only. We create this base class so that all of our entities’ code will share the same rule. Another benefit is to ensure type safety down the road.
<?php
namespace App\Domain\Administrator;
use App\Domain\Code;
readonly class AdministratorCode extends Code
{
}
The implementation of the code itself is pretty straightforward. We don’t need to do anything here other than impose the rule defined in the parent class.
The administrator entity
<?php
namespace App\Domain;
use App\Domain\Exception\ImmutableEntityCodeException;
use App\Domain\Exception\ImmutableEntityIdException;
abstract class Entity
{
public function __construct(
protected ?int $id = null,
protected ?Code $code = null
) {
}
public function getId(): ?int
{
return $this->id;
}
/**
* @throws ImmutableEntityIdException
*/
public function setId(?int $id): void
{
if ($this->id != null) {
throw new ImmutableEntityIdException($this);
}
$this->id = $id;
}
abstract public function getCode(): ?Code;
/**
* @throws ImmutableEntityCodeException
*/
abstract public function setCode(string $code): void;
abstract public function serialize(): array;
}
This is the base class for all of our entity classes. Here, we also set several rules:
All entity classes will contain an
$id
and$code
variable. This will be their main identity, although the$id
variable will not be exposed to the end user.The
setId()
function will ensure that the$id
variable is only ever set once, if it is set more than once it will throw theImmutableEntityIdException
. Which we will create later.Both the
getCode()
andsetCode()
are abstract, even though I really don’t want them to be like that. However, this is due to the limitation of PHP that I don’t know how to overcome yet. Rather than discussing it now and detracting us from the code, let’s do it in the appendix instead.We also put a PHPDoc that the
setCode()
method can throw theImmutableEntityCodeException
.We also ensure every entity will have its own
serialize()
method. As we will need to serialize them for the API responses.
<?php
namespace App\Domain\Administrator;
use App\Domain\Entity;
use App\Domain\Exception\ImmutableEntityCodeException;
class Administrator extends Entity
{
public function __construct(
?int $id,
?AdministratorCode $code,
private string $name,
private string $email
) {
parent::__construct($id, $code);
}
public function getCode(): ?AdministratorCode
{
return $this->code;
}
public function setCode(string $code): void
{
if ($this->code != null) {
throw new ImmutableEntityCodeException($this);
}
$this->code = new AdministratorCode($code);
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): void
{
$this->email = $email;
}
public function serialize(): array
{
return [
'code' => $this->code->getValue(),
'name' => $this->name,
'email' => $this->email
];
}
}
Just like the code, the entity implementation is also pretty straightforward. The only thing we add here is the same logic that prevents the code from being set more than once, just like in the parent class. We also implement our simple serialization method here.
The exceptions
<?php
namespace App\Domain\Exception;
use App\Domain\Entity;
use Exception;
class ImmutableEntityIdException extends Exception
{
public function __construct(Entity $entity)
{
parent::__construct('Entity ID cannot be modified after it is set. Entity: ' . get_class($entity));
}
}
<?php
namespace App\Domain\Exception;
use App\Domain\Entity;
use Exception;
class ImmutableEntityCodeException extends Exception
{
public function __construct(Entity $entity)
{
parent::__construct('Entity code cannot be modified after it is set. Entity: ' . get_class($entity));
}
}
Yeah, they’re pretty simple, all they do is set a custom exception message.
The code generator service
<?php
namespace App\Domain\Service;
use Ramsey\Uuid\Uuid;
class CodeGenerator
{
public static function generate(): string
{
return gmp_strval(gmp_init(str_replace('-', '', Uuid::uuid4()->toString()), 16), 62);
}
}
Finally some business logic! …if you call that business. Anyway, this is a service that generates my style of “compressed” UUIDv4. Nothing fancy, just stripping dashes and encoding it to base 62, but I think it’s pretty neat.
The application
Next, we can move on to creating our application's core business logic. The difference between the domain business logic and the application business logic seems to be pretty subtle. But in general, we want to put business logic that defines important rules or actions for our entities in the domain while business logic that coordinates multiple entities or complex actions in the application layer.
Still pretty unclear? Well, put simply, take a look at our CodeGenerator
service. The business logic there is only directly affected and required by our domain (our entities). Another example is the rule that we imposed that our entities’ IDs and codes can only be set once.
In contrast, the RequestToken
use case that will create (spoiler alert: much) later will affect multiple entities and is not actually required by any of the entities involved. For instance, the Administrator
entity doesn’t really care (or need to know) who requested the token, right?
Now, we will create our first business use case FindOneAdministratorByCode
. This use case, as you can guess from the name, will return a single administrator by their code. This will be a simple use case that we can use for building our boilerplate, we will obviously add more use cases later, but for now, it will have to do.
But wait!
Didn’t I just say that business logic related to our entities should belong to the domain? Why is it there then?
At first glance, it seems to be like that indeed. But let’s take a step back and look at it from a wider field of view. Entities cannot retrieve anything by itself. For that use case to work, you will need to have several steps:
An administrator sends an HTTP request to the API endpoint.
The endpoint will need to route the request to the appropriate controller to handle it.
Between the router and the controller, an authentication middleware will first intercept the request for authentication by validating the credentials with data stored in the database, then actually forward it to the controller if the authentication process is successful.
The controller will then validate the request contents. If the request contents are valid, the controller will first format it to the specification required by the use case (hence, we can see here that this controller is an “adapter”) before passing it to the use case.
The use case will call the appropriate port, which in turn will call the adapter implementation of that port, which is the administrator repository.
The administrator repository is the part that will actually connect to the database retrieve the data, format it (again, “adapter”), and return it all back to again to the beginning, tracing back the steps.
In summary, even though it sounds simple, we can see that it actually involves a lot of components, unlike our previous CodeGenerator
service. That’s why, this business logic belongs to the application.
The find one administrator by code use case
<?php
namespace App\Application\UseCase\Administration\Administrator;
use App\Application\Exception\AdapterException;
use App\Application\Port\Repository\AdministratorRepositoryPort;
use App\Domain\Administrator\Administrator;
use App\Domain\Administrator\AdministratorCode;
readonly class FindOneAdministratorByCode
{
public function __construct(
private AdministratorRepositoryPort $administratorRepository
) {
}
/**
* @throws AdapterException
*/
public function execute(AdministratorCode $code): ?Administrator
{
return $this->administratorRepository->findOneByCode($code);
}
}
As you can see, this use case is deceptively simple. A couple of notes:
Every business logic will have its own use case class, here, we adhere to the Single Responsibility Principle (SRP), making it easy to understand, maintain, and test.
The only method will be
execute()
, this reinforces the SRP.This method will throw an
AdapterException
if something is wrong happening at the adapter. This gives a clear separation of concern and decoupling between the use case and the adapter, if there is something wrong, let’s say, we have a database connection problem, the use case doesn’t really care about that specific problem, it will be up to the adapter to handle it and convert it (again, “adapter”) to a generic (but clear) exception.The constructor will inject the necessary dependency for the use case, this should only be a port.
The administrator repository port
<?php
namespace App\Application\Port\Repository;
use App\Application\Exception\AdapterException;
use App\Domain\Administrator\Administrator;
use App\Domain\Administrator\AdministratorCode;
interface AdministratorRepositoryPort
{
/**
* @throws AdapterException
*/
public function findOneByCode(AdministratorCode $code): ?Administrator;
}
Again, looks pretty basic.
Unlike a use case where we only have a single method per class, it is perfectly fine and logical to group similar functions into a single interface, unfortunately for now, we only have one method.
Think of it as a physical USB port, you can do multiple things with a USB port, right? You can connect your flash drive, your 2FA authenticator, your USB audio card, your monitor, your smart pet rock, etc, you get the point!
USB port is a perfect example of …port and adapter. You see, depending on the device plugged into it, a single port can be used for multiple things!
And regarding the multiple methods, let's say you connect your flash drive, you can do multiple things to it too, you can read files from it, write files to it, format it, and possibly many others too (maybe)!
The PDO repository adapter
<?php
namespace App\Adapter\Repository\Pdo;
use PDO;
abstract class PdoRepository
{
public function __construct(protected readonly PDO $pdo)
{
}
}
As we will have multiple repositories, we created this base class to reduce code duplication. For now, since we only have one repository, we simply inject the PDO object to be used by the repository.
<?php
namespace App\Adapter\Repository\Pdo;
use App\Application\Exception\AdapterException;
use App\Application\Port\Repository\AdministratorRepositoryPort;
use App\Domain\Administrator\Administrator;
use App\Domain\Administrator\AdministratorCode;
use PDOException;
class AdministratorRepository extends PdoRepository implements AdministratorRepositoryPort
{
/**
* @throws AdapterException
*/
public function findOneByCode(AdministratorCode $code): ?Administrator
{
try {
$stmt = $this->pdo->prepare('SELECT * FROM administrators_view WHERE code = :code LIMIT 1');
$stmt->bindValue(':code', $code->getValue());
$stmt->execute();
$row = $stmt->fetch();
} catch (PDOException $e) {
throw new AdapterException($e);
}
if (!$row) {
return null;
}
return new Administrator(
$row['id'],
new AdministratorCode($row['code']),
$row['name'],
$row['email']
);
}
}
Here, we implement the AdministratorRepositoryPort
. The code itself is again pretty straightforward, you can see the “adapter” action in the works:
It converts data from the database into the
Administrator
entity.It converts the
PDOException
into theAdapterException
.
So far, we have covered about 25% of our application. But, to prevent this article from getting too long, we will stop here and implement the administration port in the next one!
The appendix: problem with PHP generics
Unfortunately, as per version 8.3 (or 8.4), PHP does not have generics yet. We do have covariance and contravariance, but compared to other languages, like Java, it is quite lacking. Let’s revisit our domain classes and see how can we implement them if we were using Java.
package com.example;
public abstract class Code {
private final String value;
public Code(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
package com.example;
public class AdministratorCode extends Code {
public AdministratorCode(String value) {
super(value);
}
}
PHP has won so far. See how much simpler it is in PHP instead of Java? But wait…
package com.example;
import java.io.Serializable;
import java.util.Map;
public abstract class Entity<TCode extends Code> implements Serializable {
protected Integer id;
protected TCode code;
public Entity() {
this(null, null);
}
public Entity(Integer id, TCode code) {
this.id = id;
this.code = code;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
if (this.id != null) {
throw new IllegalStateException("ID already set");
}
this.id = id;
}
public TCode getCode() {
return code;
}
public void setCode(TCode code) {
if (this.code != null) {
throw new IllegalStateException("com.example.Code already set");
}
this.code = code;
}
public abstract Map<String, Object> serialize();
}
package com.example;
import java.util.HashMap;
import java.util.Map;
public class Administrator extends Entity<AdministratorCode> {
private String name;
private String email;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
@Override
public Map<String, Object> serialize() {
return new HashMap<>() {{
put("id", Integer.toString(id));
put("code", code);
put("name", name);
put("email", email);
}};
}
}
Whoa! Look at those generics! The code is so much cleaner now while maintaining the same functionality, i.e. you cannot set another code type to the administrator!
Now, I tried several things to emulate those in PHP, but neither feels clean or works correctly. I know that you can sort of do this with PHPStan annotation, but then again, it is still just an annotation, it doesn’t actually prevent you from setting another code type to the administrator.
If anyone knows how to implement this better, please enlighten me.
Subscribe to my newsletter
Read articles from pulunomoe directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by