Domain Driven Design with Laravel 9: User domain

  • avatar
  • 6.3K Views
  • 15 Likes
  • 11 mins read

Modern web frameworks take one group of related concepts and split it across multiple places throughout your codebase. Laravel provides a very clear structure with large variety of tools to make the development easier and faster. Along with the huge community it makes Laravel a great option for most starting projects.

Building scalable applications, instead, requires a different approach. Have you ever heard from a client to work on controllers or review the models folder? Probably never - they ask you to work on invoicing, clients management or users. These concept groups are called domains.

Let's make a practical exercise applying Domain Driven Design. Our goal is to create domain logic for users that can be universally used and extended in any Laravel project. Taking advantage of the framework power at the same time we meet complex business requirements.

Prerequisites

Understanding of Domain Driven Design and some basic concepts:

Laravel 9 will be used in this guide. To be more specific, the modified version prepared in Domain Driven Design with Laravel 9 that adapts framework's structure to the Domain Driven Design. Some updates have been performed to the original code that are mentioned below.

The following command will create the project in the laravel9-ddd folder and install the required dependencies:

composer create-project hibit-dev/laravel9-ddd

Upgraded dependencies and PHP version

As time passes, some packages are deprecated or upgraded. We took the time to check and upgrade all the dependencies, as well as, Laravel framework to the newest available version. In order to use enums and other features PHP8.1 is set as required.

Added unique identifiers

The UID component provides utilities to work with unique identifiers (UIDs) such as ULIDs. They provide 128-bit compatibility with UUID, they are lexicographically sortable and they are encoded as 26-character strings. ULID will be used as unique identifier for user aggregate.

Extended shared value objects and exceptions

Common value objects list has been extended to 6 implementations and 2 interfaces. All of them are highly reusable and placed in the shared domain: boolean, string, integer, float, ulid (+ interface) and date time (+ interface) value objects.

In addition, generic exceptions were created in the shared domain. They will be part of our domain logic.

Implemented criteria pattern

Criteria pattern or Filter pattern is a design pattern that enables developers to filter a set of objects using different criteria and chaining them in a decoupled way through logical operations. This pattern combines multiple criteria to obtain single one.

Shared domain logic will contain abstract criteria implementation that each specific criteria should extend from. It contains information about pagination, offset and sorting (field & direction). The specific criteria, in turn, will contain the exact fields to filter. The criteria object is passed to the repository and applied when building the query to retrieve data.

User domain layer

Domain layer contains aggregates, value objects (VOs), data transfer objects (DTOs), domain events, entities, models, etc... Since this layer is where abstractions are made, the design of interfaces are included in the domain layer

Created user domain includes very basic aggregate with corresponding value objects (ID, name & email), the repository interface (contract) and search criteria to retrieve filtered data. In case of missing user, specific user domain exception is launched.

Domain layer for user

Note: instead of auto incremented numbers ULIDs are used to identify users.

Repository pattern

Repositories are classes or components that encapsulate the logic required to access data sources. They centralize common data access functionality, providing better maintainability and decoupling the infrastructure or technology used to access databases from the domain layer.

Repositories contract will always lay in Domain layer in DDD and only one should be created for each aggregate or aggregate root. The following contract applies to the user aggregate:

interface UserRepository
{
public function create(User $user): void;

public function update(User $user): void;

/**
* @throws UserNotFoundException
*/
public function findById(Id $userId): User;

public function searchById(Id $userId): ?User;

public function searchByCriteria(UserSearchCriteria $criteria): array;

public function delete(User $user): void;
}

Implementation

The concrete implementation for repositories are always located in the infrastructure layer. We've chosen MySQL and Eloquent for this repository implementation. Eloquent models will be located in app/Infrastructure/Laravel/Model folder as framework dependency and repository implementations within the corresponding folder in app/Infrastructure.

Repository implementation has also the responsibility to apply the search criteria to retrieve filtered data:

public function searchByCriteria(UserSearchCriteria $criteria): array
{
$userModel = new UserModel();

if (!empty($criteria->email())) {
$userModel = $userModel->where('email', 'LIKE', '%' . $criteria->email() . '%');
}

if (!empty($criteria->name())) {
$userModel = $userModel->where('name', 'LIKE', '%' . $criteria->name() . '%');
}

if ($criteria->pagination() !== null) {
$userModel = $userModel->take($criteria->pagination()->limit()->value())
->skip($criteria->pagination()->offset()->value());
}

if ($criteria->sort() !== null) {
$userModel = $userModel->orderBy(
$criteria->sort()->field()->value(),
$criteria->sort()->direction()->value()
);
}

return array_map(
static fn (UserModel $user) => self::map($user),
$userModel->get()->all()
);
}

Note: map method transforms each result to user object.

Database

The schema used for the project can be easily recreated with the following command:

CREATE TABLE `hibit_users` (
`id` varchar(26) COLLATE utf8mb4_unicode_ci NOT NULL,
`email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

As mentioned before, the identifier for each user is an ULID encoded as 26-character strings.

Binding interfaces

Laravel does not provide class auto wiring and we should manually specify where concrete implementation of the UserRepository interface is located. It can be done within AppServiceProvider.php register method.

User repository binding

Defined the binding, every use of repository interface will load the concrete implementation.

Controller, routes and testing

Despite SOLID principles, and only to have more comprehensive presentation, we have created unique resource controller in UI layer to interact with external world retrieving and managing users.

class UserController extends Controller
{
public function index(Request $request, UserRepository $userRepository): JsonResponse;

public function store(UserRepository $userRepository): JsonResponse;

public function show(UserRepository $userRepository, string $id): JsonResponse;

public function update(Request $request, UserRepository $userRepository, string $id): JsonResponse;

public function destroy(UserRepository $userRepository, string $id): JsonResponse;
}

The implementation of each method is straightforward and available in our GitHub repository. To better understand what is the meaning of each method there is a brief explanation below.

  • index: retrieve paginated list of users (10 elements per page).

  • store: generates 3 random users in DB.

  • show: retrieves user with provided user ID.

  • update: updates user with provided user ID.

  • destroy: deletes user from DB with provided user ID.

A dedicated route has been defined in routes/api.php for each controller method:

Route::get('users', 'UserController@index');
Route::resource('user', 'UserController')->only(['store', 'show', 'update', 'destroy']);

Note: make sure to add the corresponding namespace in RouteServiceProvider to correctly locate the controller.

Route service provider

Local development server

To quickly start Laravel local development server use artisan command:

php artisan serve

The terminal will be blocked by the process and it will show the URL to access the APP. Usually it will be something similar to:

http://127.0.0.1:8000

Testing

Let's create 3 random users to work with them.

POST http://127.0.0.1:8000/api/user

The endpoint return HTTP CREATED (201) code and the list of created users.

{
"users": [
{
"id": "01GDWSP41MG2NWP1J9K7HS34N2",
"email": "email_1",
"name": "name_1",
"created_at": "2022-09-26 11:42:28.916381 UTC",
"updated_at": null
},
{
"id": "01GDWSP427FYKV89CDYZ2R6TAT",
"email": "email_2",
"name": "name_2",
"created_at": "2022-09-26 11:42:28.935690 UTC",
"updated_at": null
},
{
"id": "01GDWSP42QMNX1HHSPGD1SSWZD",
"email": "email_3",
"name": "name_3",
"created_at": "2022-09-26 11:42:28.951487 UTC",
"updated_at": null
}
]
}

The list of users can be retrieved with another endpoint.

GET http://127.0.0.1:8000/api/users

By default, the list is limited to 10 elements per page. To apply pagination to the endpoint, add offset to the query string. Generally, it should be proportional to the number of elements per page but any positive integer can be passed.

GET http://127.0.0.1:8000/api/users?offset=10

On top of that, you can apply search criteria. As per code, only email and name fields can be filtered accepting partial match for both of them.

GET http://127.0.0.1:8000/api/users?name=name&email=email&offset=0

Providing user ID, a specific user can be retrieved.

GET http://127.0.0.1:8000/api/user/01GDWSP41MG2NWP1J9K7HS34N2

The output is very similar to the list endpoint with the difference that it contains only one result.

{
"user": {
"id": "01GDWSP41MG2NWP1J9K7HS34N2",
"email": "email_1",
"name": "name_1",
"created_at": "2022-09-26 11:42:28.000000 UTC",
"updated_at": "2022-09-26 11:42:28.000000 UTC"
}
}

User update is done with PATCH method, sending email and/or name values in the request body.

PATCH http://127.0.0.1:8000/api/user/01GDWSP41MG2NWP1J9K7HS34N2

BODY: email=new_email&name=new_name

Finally, DELETE method is implemented to remove user providing an identifier:

DELETE http://127.0.0.1:8000/api/user/01GDWSP41MG2NWP1J9K7HS34N2

It will return HTTP NO CONTENT (204) code after deleting the user from DB.

Conclusion

Note that so far nothing has changed in the way we use Laravel. We still have our Kernels, Providers, Exception Handler, Rules, Mails, etc... inside the app folder. We are also making use of the famous Laravel Eloquent to build queries, retrieve and persist data.

We've created a simple and universal user domain, other domain requirements can be added on top of it. Remember that there is no unique way of defining things. Almost everything depends on the project you're working on.

Credits

Official GitHub: https://github.com/hibit-dev/laravel9-ddd

 Join Our Monthly Newsletter

Get the latest news and popular articles to your inbox every month

We never send SPAM nor unsolicited emails

0 Comments

Leave a Reply

Your email address will not be published.

Replying to the message: View original

Hey visitor! Unlock access to featured articles, remove ads and much more - it's free.