PHP ❤ Enterprise
Who am I?
Christian Nastasi
IT Training Manager @Facile.it
Developing things starting
from 2000
Member of the Laravel Community
Nerd T-shirt lover
What I did the last
(almost) 3 years?
- Worked on a legacy project (10+ years old)
- Led and coached the team
- Saw what a legacy code can become
- Refactored part of that legacy code
- Built new stuff on that project
What's NOT the topic of the talk?
We are not covering all the practices and solutions needed for working on a legacy project
The topic is pretty big, and in order to give some value, I focused the talk in small and limited set of problems.
What's the topic of the talk?
There are some bad patterns that are recurrent and needs attention in order to avoid an increse of our technical debt.
We are going to simulate an evolving green field project.
I will give you some design hints in order to keep your architecture clean and maintanable.
Disclaimer
This talk is based on my personal experience and studies
Due lack of time, some topics could be touched only slightly
May contains traces of houmour
The code showed in this talk are intentionally simple and it omits sometimes parts for educational purpose
Context
What's an Enterprise Project?
No formal definition found, but for me it has:
- A very long life (years)
- Contains domain specific business logics
- Could be very structured
- It is a live project: often has to change / evolve
- There is one or more teams working on it
Common issues
- Often it is partially refactored (Frankenstein architecture)
- Lack of time means shortcuts (Growth of wrong dependencies and technical debt)
- It grows in complexity within the time (cost of maintanance exponential high)
- Often, domain logics, framework and persistence are tightly bounded
- Change requests / evolutions to the legacy are potentially disruptive
- There is no or low test coverage
In short terms
High technical debt
High maintance cost
High evolution cost
Low confidence in changes
But, as they say, facts are better than words
The use case
We have to develop an application for a library.
Features:
Add a book into the library
Search for a book
Feature:
Add a book into
the library
A simple implementation
Route::post('books', AddBookController::class);
routes/api.php
class AddBookController
{
public function __invoke (Request $request): JsonResponse
{
$validatedBookData = $request->validate([
'isbn' => 'required|...', // simplified
'title' => 'required|min:2',
'authors' => 'required|array|...' // simplified,
'publishedAt' => 'required|date|...' // simplified
])
$book = Book::create($validatedBookData);
return response()->json($book, 201)
}
}
app/Http/Controllers/AddBookController.php
class Book extends Model
{
protected $primaryKey = 'isbn';
protected $fillable = ['isbn', 'title', 'authors', 'publishedAt'];
}
app/Models/Book.php
Logic blocks
class AddBookController
{
public function __invoke (Request $request): JsonResponse
{
$validatedBookData = $request->validate([
'isbn' => 'required|...', // simplified
'title' => 'required|min:2',
'authors' => 'required|array|...' // simplified,
'publishedAt' => 'required|date|...' // simplified
])
$book = Book::create($validatedBookData);
return response()->json($book, 201)
}
}
Validate
Persist
Response
Get the input
Change request:
Insert a book into
the library
Using the command line
Easy, right?
Duplicated code
Solution:
Don't repeat yourself
Just move the logic outside
class BookService
{
public function add (array $data): void
{
$validator = Validator::make($data, [
'isbn' => 'required|...', // simplified
'title' => 'required|min:2',
'authors' => 'required|array|...' // simplified,
'publishedAt' => 'required|date|...' // simplified
]);
if ($validator->fails()) {
throw new InvalidBookException($validator->errors()->toArray());
}
$book = Book::create($validator->validated());
}
}
Recap
Don't repeat yourself
Try to centralize your business logic
Apply the single responsibility principle
Feature:
Search for a book
Search for a book
Change request:
I would like that our users are able to find a book using words contained in the content of the book
We tried, but with the amout of books in our database a full text search is too slow with the actual technology stack that we have.
Then change it!
Typical problems:
-
We have a change request / evolution that are potentially disruptive in the actual technology stack
-
The database used right now are not the best choice for that use case, we should change it, at least for the search
-
We are using an ORM that aren't compatible with the technology we should use. Changing it means to touch the code in a several places, with the risk of introducing side effects
-
We are too coupled with the framework
Solution:
Repository Pattern
Book Repository
interface BookRepository
{
public function add (array $data): void;
public function findByIsbn(string $isbn): ?array;
/** .... */
}
class EloquentBookRepository implements BookRepository
{
public function add (array $data): void {
/* Input Validation */
Book::create($data);
}
public function findByIsbn(string $isbn): ?array {
return Book::find($isbn)->toArray();
}
/** .... */
}
Recap
Coupling business logic and infrastructure is risky
Depend on abstractions, not on concretions
Why should I reinvent the wheel?
Wasted DB Access
Possible inconsistent rule between logics
Problem: Wasted db access
Some validation rule requires check on database
We waste time and resources doing the same operations every time per session
Those rules are duplicated
Solution: Caching
But cache is not a silver bullet
Ask yourself:
How frequently the information changes?
How often I need that information?
Cached Repository
final class CachedBookRepository implements BookRepository
{
public function __construct(
private BookRepository $bookRepository,
private BookCache $bookCache
) {}
public function add (array $data):void
{
$this->bookRepository->add($data);
}
public function findByIsbn(string $isbn):?array
{
if (!$bookCache->has($isbn)) {
$book = $this->bookRepository->findByIsbn($isbn);
$bookCache->set($book);
}
return $bookCache->get($isbn);
}
}
interface BookCache
{
public function has(string $isbn):bool;
public function get(string $isbn):array;
public function set(array $book):void;
}
Recap
Try to optimize I/O access when possible
Hide those optimizations behind abstractions
Problem: Inconsistency
We cannot trust the inputs, because we don't know from where the logic will be used in the future
We can't be sure that we are using the same validation rules everywhere
The validation rules are duplicated
Inconsistent data
Inconsistent behaviour
But before continuing, let's talk about primitives, types and data modeling in general
Primitives: The root of all evils
What's an age?
What's a phone number?
Integer
> 18
< 99
Only italian numbers
Prefix + 8 o 9 digits
Start with +
a number
a string
So I have to validate every time, everywhere, to be sure that the value is correct
Watch this
There's a bug. Can you spot it?
class VeryUsefullClass
{
public function doSomething (int &$age): void {
if ($age = 18) {
// do your stuff
}
else if ($age > 18) {
// do other stuff
}
}
}
There's nothing that can prevent this situation
Are we sure?
What if we can define our own types?
What's a book?
author
title
ISBN
publishing date
13 digits
Have a specific format
"978-3-16148410-0"
"The Hitchhiker's Guide to the Galaxy"
At least 2 characters
[ "Douglas Adams" ]
At least one
Exists into the authors registry
At least 2 characters each
"September 27, 1995"
Valid date
Past the first book printed
Not too far into the future
Solution: Value Objects
final class Isbn
{
private function __construct(public readonly string $value)
{
$this->assertIsThirteenCharacters($this->value);
$this->assertHasAValidFormat($this->value);
}
private function assertIsThirteenCharacters (string $value):void
{
strlen($value) == 13 or throw InvalidIsbn::tooShort($value);
}
private function assertHasAValidFormat (string $value):void
{
// ISBN specific rules
}
public function __toString ():string
{
return $this->value;
}
public function equalsTo (Isbn $isbn):bool
{
return $isbn->value === $this->value;
}
public static function fromString(string $isbn): Isbn
{
return new Isbn($isbn);
}
}
Solution: Value Objects
Self validating
Immutable
Describes values / concepts of your domain
Protect you from the past yourself
Comparable
If you have an instance of it, then you are pretty sure that your data is correct
Solution: Entities
final class Book
{
public function __construct (
public readonly Isbn $isbn,
public readonly Title $title,
public readonly Authors $authors,
public readonly PublishingDate $publishedAt
) {}
public function equalsTo (Book $book):bool
{
return $book->isbn->equalsTo($this->isbn);
}
public static function create (
string $isbn,
string $title,
array $authors,
string $publishedAt
): Book {
return new Book (
Isbn::fromString($isbn),
Title::fromString($title),
Authors::fromArray($array),
PublishingDate::fromString($publishedAt)
);
}
public function toArray ():array
{
return [
'isbn' => (string) $this->isbn,
'title' => (string) $this->title,
'authors' => $this->authors->toArray(),
'publishAt' => (string) $this->publishedAt
];
}
}
What's next?
Aggregates
Domain Events
Hierarchic Errors
Factories
Use cases / services
Bounded contexts
...
Welcome into the DDD realm
Recap
Using a strong type system, prevent you from bugs and data inconsistency
The big picture
Let's refactor: Add Book
class AddBookRequest
{
public function rules():array
{
return [
'isbn' => 'required',
'title' => 'required',
'authors' => 'required',
'publishedAt' => 'required'
];
}
/* ... */
}
Controller
class AddBookController
{
public function __construct (private readonly AddBook $addBook) {}
public function __invoke (AddBookRequest $request): JsonResponse
{
$book = Book::create(...$request->validated()); // It's our entity
($this->addBook)($book);
return response()->json($book->toArray(), 201)
}
}
Let's refactor: Add Book
final class AddBook
{
public function __construct (
private readonly BookRepository $bookRepository,
private readonly EventDispatcher $dispatcher
) {}
public function __invoke (Book $book): void
{
$this->bookRepository->add ($book);
$this->dispatcher->dispatch (new BookAdded ($book));
}
}
Service
Let's refactor: Add Book
interface BookRepository
{
public function add (Book $book):void;
public function findByIsbn (Isbn $isbn):?Book;
}
Repository
Let's refactor: Get Book
class GetBookController
{
public function __construct (private readonly GetBookByIsbn $getBookByIsbn) {}
public function __invoke (string $isbnAsString): JsonResponse
{
$isbn = Isbn::fromString ($isbnAsString);
$book = ($this->getBookByIsbn)($isbn); // Throws BookNotFound
return response()->json($book->toArray(), 200)
}
}
Controller
Let's refactor: Get Book
final class GetBookByIsbn
{
public function __construct (private readonly BookRepository $bookRepository) {}
public function __invoke (Isbn $isbn): Book|never
{
$book = $this->bookRepository->findByIsbn ($isbn);
return $book ?? throw new BookNotFound ($isbn);
}
}
Service
Let's refactor: Get Book
interface BookCache
{
public function has (Isbn $isbn):bool;
public function get (Isbn $isbn):?Book;
public function set (Book $book):void;
}
Cache
Putting all together
Don't put your business logic into the entry points
Inject the logic from outside instead
Putting all together
Follow the rule: one logic, one place
Don't put the same logic in two places
Putting all together
Don't let your business logic knows about frameworks, libraries or infrastructures
Use abstractions in your business logics and let the concrete class knows about the implementation details.
Putting all together
Avoid repeating heavy I/O operations
Use caching policies when it make sense
Putting all together
Build your types instead.
Every class is a type
Don't use primitives
Thanks
And also
Conclusion
PHP Love Enterprise
By Nastasi Christian
PHP Love Enterprise
- 179